0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Go言語でREST APIを作ってみる⑤(Comment:コメント)【Echo + GORM + MySQL】

Last updated at Posted at 2023-05-08

はじめに

前回の続きで、APIの詳細の実装をしていきます。
その中でも今回はComment周りを記事にしていきます。

過去の記事はこちら
Go言語でREST APIを作ってみる【Echo + GORM + MySQL】
Go言語でREST APIを作ってみる②(User)【Echo + GORM + MySQL】
Go言語でREST APIを作ってみる③(Spot)【Echo + GORM + MySQL】
Go言語でREST APIを作ってみる④(Record:記録)【Echo + GORM + MySQL】

ディレクトリ構成

今回使用するファイルは以下です。

.
├── handler
│   └── comment.go
├── model
│   └── comment.go
├── repository
│   └── db.go
├── router
│   └── router.go
├── test
    ├── .env
│   └── comment_test.go
├── .env
└── main.go

実装

Comment周りの実装をしていきます。

モデルの作成

model/comment.go
package model

import "time"

type Comment struct {
	ID        int64     `json:"id"`
	UserID    int64     `json:"user_id"`
	RecordID  int64     `json:"record_id"`
	UserName  string    `json:"user_name"` // TODO: remove consideration
	Text      string    `json:"text"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

UserNameはリレーションから持ってこれるとベストなので、一旦考慮事項にしています。

DBの作成

過去に作成したrepository/db.goの追記していきます。

repository/db.go
package repository

import (
	"log"
	"os"
	"time"

	. "bike_noritai_api/model"

	"github.com/joho/godotenv"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

var (
	DB  *gorm.DB
	err error
)

func init() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	dsn := os.Getenv("DEV_DB_DNS")
	if os.Getenv("ENV") == "test" {
		dsn = os.Getenv("TEST_DB_DNS")
	}

	DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatalln(dsn + "database can't connect")
	}

	DB.Migrator().DropTable(&User{})
	DB.Migrator().DropTable(&Spot{})
	DB.Migrator().DropTable(&Record{})
	DB.Migrator().DropTable(&Comment{}) // 追記

	DB.AutoMigrate(&User{})
	DB.AutoMigrate(&Spot{})
	DB.AutoMigrate(&Record{})
	DB.AutoMigrate(&Comment{}) // 追記

	users := []User{
		{
			Name:       "tester1",
			Email:      "tester1@bike_noritai_dev",
			Password:   "password",
			Area:       "東海",
			Prefecture: "三重県",
			Url:        "http://test.com",
			BikeName:   "CBR650R",
			Experience: 5,
		},
		{
			Name:       "tester2",
			Email:      "tester2@bike_noritai_dev",
			Password:   "password",
			Area:       "関東",
			Prefecture: "東京都",
			Url:        "http://test.com",
			BikeName:   "CBR1000RR",
			Experience: 10,
		},
	}
	DB.Create(&users)

	spots := []Spot{
		{
			UserID:      1,
			Name:        "豊受大神宮 (伊勢神宮 外宮)",
			Image:       "http://test.com",
			Type:        "観光",
			Address:     "三重県伊勢市豊川町279",
			HpURL:       "https://www.isejingu.or.jp/about/geku/",
			OpenTime:    "5:00~18:00",
			OffDay:      "",
			Parking:     true,
			Description: "外宮から行くのが良いみたいですよ。",
			Lat:         34.48786428571363,
			Lng:         136.70372958477844,
		},
		{
			UserID:      1,
			Name:        "伊勢神宮(内宮)",
			Image:       "http://test.com",
			Type:        "観光",
			Address:     "三重県伊勢市宇治館町1",
			HpURL:       "https://www.isejingu.or.jp/",
			OpenTime:    "5:00~18:00",
			OffDay:      "",
			Parking:     true,
			Description: "日本最大の由緒正しき神社です。",
			Lat:         34.45616423029016,
			Lng:         136.7258739014393,
		},
	}
	DB.Create(&spots)

	records := []Record{
		{
			UserID:      users[0].ID,
			SpotID:      spots[0].ID,
			Date:        time.Now().Format("2023-01-01"),
			Weather:     "晴れ",
			Temperature: 23.4,
			RunningTime: 4,
			Distance:    120.4,
			Description: "最高のツーリング日和でした!",
		},
		{
			UserID:      users[0].ID,
			SpotID:      spots[1].ID,
			Date:        time.Now().Format("2023-01-01"),
			Weather:     "曇り",
			Temperature: 26.1,
			RunningTime: 7,
			Distance:    184.1,
			Description: "なんとか天気が持って良かったです!",
		},
		{
			UserID:      users[1].ID,
			SpotID:      spots[0].ID,
			Date:        time.Now().Format("2023-01-01"),
			Weather:     "雨",
			Temperature: 13.4,
			RunningTime: 2,
			Distance:    50.6,
			Description: "朝から雨で大変でした。",
		},
		{
			UserID:      users[1].ID,
			SpotID:      spots[1].ID,
			Date:        time.Now().Format("2023-01-01"),
			Weather:     "晴れ",
			Temperature: 33.4,
			RunningTime: 6,
			Distance:    220.4,
			Description: "バイク暑すぎます!!!",
		},
	}
	DB.Create(&records)
    
    // 以下追記
	comments := []Comment{
		{
			UserID:   users[0].ID,
			RecordID: 1,
			UserName: users[0].Name,
			Text:     "AAAAAAAAAAAAAAA",
		},
		{
			UserID:   users[0].ID,
			RecordID: 1,
			UserName: users[0].Name,
			Text:     "BBBBBBBBBBBBBBBBB",
		},
		{
			UserID:   users[1].ID,
			RecordID: 2,
			UserName: users[0].Name,
			Text:     "CCCCCCCCCCCCCCCCC",
		},
		{
			UserID:   users[1].ID,
			RecordID: 2,
			UserName: users[0].Name,
			Text:     "DDDDDDDDDDDDDDDDDD",
		},
	}
	DB.Create(&comments)
}

DBの細かな解説は前回の記事を参照してください

Router

router.go
package router

import (
	"bike_noritai_api/handler"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func NewRouter() *echo.Echo {
	e := echo.New()
	e.Use(middleware.CORS())
	e.Use(middleware.Logger())

	e.GET("/api/users", handler.GetUsers)
	e.GET("/api/users/:user_id", handler.GetUser)
	e.POST("/api/users", handler.CreateUser)
	e.PATCH("/api/users/:user_id", handler.UpdateUser)
	e.DELETE("/api/users/:user_id", handler.DeleteUser)

	e.GET("/api/spots", handler.GetSpots)
	e.GET("/api/spots/:spot_id", handler.GetSpot)
	e.GET("/api/users/:user_id/spots", handler.GetUserSpot)
	e.POST("/api/users/:user_id/spots", handler.CreateSpot)
	e.PATCH("/api/users/:user_id/spots/:spot_id", handler.UpdateSpot)
	e.DELETE("/api/users/:user_id/spots/:spot_id", handler.DeleteSpot)

	e.GET("/api/users/:user_id/records", handler.GetUserRecords)
	e.GET("/api/spots/:spot_id/records", handler.GetSpotRecords)
	e.GET("/api/records/:record_id", handler.GetRecord)
	e.POST("/api/users/:user_id/spots/:spot_id/records", handler.CreateRecord)
	e.PATCH("/api/users/:user_id/spots/:spot_id/records/:record_id", handler.UpdateRecord)
	e.DELETE("/api/users/:user_id/spots/:spot_id/records/:record_id", handler.DeleteRecord)

    // 以下追記
	e.GET("/api/users/:user_id/comments", handler.GetUserComments)
	e.GET("/api/records/:record_id/comments", handler.GetRecordComments)
	e.POST("/api/users/:user_id/records/:record_id/comments", handler.CreateComment)
	e.PATCH("/api/users/:user_id/records/:record_id/comments/:comment_id", handler.UpdateComment)
	e.DELETE("/api/users/:user_id/records/:record_id/comments/:comment_id", handler.DeleteComment)

	return e
}

Handler

handler/comment.go
package handler

import (
	"errors"
	"net/http"
	"strconv"

	"github.com/labstack/echo/v4"
	"gorm.io/gorm"

	. "bike_noritai_api/model"
	. "bike_noritai_api/repository"
)

func GetUserComments(c echo.Context) error {
	comments := []Comment{}

	userID := c.Param("user_id")
	if userID == "" {
		return c.JSON(http.StatusBadRequest, "user ID is required")
	}

	if err := DB.Where("user_id = ?", userID).Find(&comments).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return c.JSON(http.StatusNotFound, "comments not found")
		}
		return err
	}

	return c.JSON(http.StatusOK, comments)
}

func GetRecordComments(c echo.Context) error {
	comments := []Comment{}

	recordID := c.Param("record_id")
	if recordID == "" {
		return c.JSON(http.StatusBadRequest, "record ID is required")
	}

	if err := DB.Where("record_id = ?", recordID).Find(&comments).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return c.JSON(http.StatusNotFound, "comments not found")
		}
		return err
	}

	return c.JSON(http.StatusOK, comments)
}

func CreateComment(c echo.Context) error {
	comment := Comment{}

	if err := c.Bind(&comment); err != nil {
		return err
	}

	userID, _ := strconv.ParseInt(c.Param("user_id"), 10, 64)
	if userID == 0 {
		return c.JSON(http.StatusBadRequest, "user ID is required")
	}

	recordID, _ := strconv.ParseInt(c.Param("record_id"), 10, 64)
	if recordID == 0 {
		return c.JSON(http.StatusBadRequest, "record ID is required")
	}

	comment.UserID = userID
	comment.RecordID = recordID

	DB.Create(&comment)
	return c.JSON(http.StatusCreated, comment)
}

func UpdateComment(c echo.Context) error {
	comment := new(Comment)

	userID, _ := strconv.ParseInt(c.Param("user_id"), 10, 64)
	if userID == 0 {
		return c.JSON(http.StatusBadRequest, "user ID is required")
	}

	recordID, _ := strconv.ParseInt(c.Param("record_id"), 10, 64)
	if recordID == 0 {
		return c.JSON(http.StatusBadRequest, "record ID is required")
	}

	commentID := c.Param("comment_id")
	if commentID == "" {
		return c.JSON(http.StatusBadRequest, "comment ID is required")
	}

	if err := DB.First(&comment, commentID).Error; err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

	if comment.UserID != userID {
		return c.JSON(http.StatusBadRequest, "user and comment do not match")
	}

	if comment.RecordID != recordID {
		return c.JSON(http.StatusBadRequest, "record and comment do not match")
	}

	if err := c.Bind(&comment); err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

	if err := DB.Model(&comment).Where("id=?", comment.ID).Updates(&comment).Error; err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

	return c.JSON(http.StatusCreated, comment)
}

func DeleteComment(c echo.Context) error {
	comment := new(Comment)

	userID, _ := strconv.ParseInt(c.Param("user_id"), 10, 64)
	if userID == 0 {
		return c.JSON(http.StatusBadRequest, "user ID is required")
	}

	recordID, _ := strconv.ParseInt(c.Param("record_id"), 10, 64)
	if recordID == 0 {
		return c.JSON(http.StatusBadRequest, "record ID is required")
	}

	commentID := c.Param("comment_id")
	if commentID == "" {
		return c.JSON(http.StatusBadRequest, "spot ID is required")
	}

	if err := DB.First(&comment, commentID).Error; err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

	if comment.UserID != userID {
		return c.JSON(http.StatusBadRequest, "user and comment do not match")
	}

	if comment.RecordID != recordID {
		return c.JSON(http.StatusBadRequest, "record and comment do not match")
	}

	if err := DB.Where("id = ?", commentID).Delete(&comment).Error; err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

	return c.JSON(http.StatusNoContent, comment)
}

テスト

test/comment_test.go
package test

import (
	"bytes"
	"encoding/json"
	"errors"
	"gorm.io/gorm"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"

	. "bike_noritai_api/model"
	. "bike_noritai_api/repository"
	. "bike_noritai_api/router"
)

func TestGetUserComments(t *testing.T) {
	router := NewRouter()
	req := httptest.NewRequest(http.MethodGet, "/api/users/1/comments", nil)
	res := httptest.NewRecorder()
	router.ServeHTTP(res, req)

	if res.Code != http.StatusOK {
		t.Errorf("unexpected status code: got %v, want %v", res.Code, http.StatusOK)
	}

	expectedBody := `"id":1,"user_id":1,"record_id":1,"user_name":"tester1","text":"AAAAAAAAAAAAAAA"`

	expectedBody2 := `"id":2,"user_id":1,"record_id":1,"user_name":"tester1","text":"BBBBBBBBBBBBBBBBB"`

	if !strings.Contains(res.Body.String(), expectedBody) {
		t.Errorf("unexpected response body: got %v, want %v", res.Body.String(), expectedBody)
	}

	if !strings.Contains(res.Body.String(), expectedBody2) {
		t.Errorf("unexpected response body: got %v, want %v", res.Body.String(), expectedBody2)
	}
}

func TestGetRecordComments(t *testing.T) {
	router := NewRouter()
	req := httptest.NewRequest(http.MethodGet, "/api/records/2/comments", nil)
	res := httptest.NewRecorder()
	router.ServeHTTP(res, req)

	if res.Code != http.StatusOK {
		t.Errorf("unexpected status code: got %v, want %v", res.Code, http.StatusOK)
	}

	expectedBody := `"id":3,"user_id":2,"record_id":2,"user_name":"tester1","text":"CCCCCCCCCCCCCCCCC"`

	expectedBody2 := `"id":4,"user_id":2,"record_id":2,"user_name":"tester1","text":"DDDDDDDDDDDDDDDDDD"`

	if !strings.Contains(res.Body.String(), expectedBody) {
		t.Errorf("unexpected response body: got %v, want %v", res.Body.String(), expectedBody)
	}

	if !strings.Contains(res.Body.String(), expectedBody2) {
		t.Errorf("unexpected response body: got %v, want %v", res.Body.String(), expectedBody2)
	}
}

func TestCreateComment(t *testing.T) {
	router := NewRouter()

	comment := Comment{
		UserName: "Tester",
		Text:     "EEEEEEEEEEEEEEEEEEEE",
	}

	reqBody, err := json.Marshal(comment)
	if err != nil {
		t.Fatalf("failed to marshal request body: %v", err)
	}

	var userID int64 = 1
	var recordID int64 = 1

	req := httptest.NewRequest(http.MethodPost, "/api/users/"+strconv.Itoa(int(userID))+"/records/"+strconv.Itoa(int(recordID))+"/comments", bytes.NewBuffer(reqBody))
	req.Header.Set("Content-Type", "application/json")
	res := httptest.NewRecorder()
	router.ServeHTTP(res, req)

	if res.Code != http.StatusCreated {
		t.Errorf("expected status code %v but got %v", http.StatusCreated, res.Code)
	}

	var resBody Comment
	if err := json.Unmarshal(res.Body.Bytes(), &resBody); err != nil {
		t.Fatalf("failed to unmarshal response body: %v", err)
	}
	if resBody.ID == 0 {
		t.Errorf("expected spot ID to be non-zero but got %v", resBody.ID)
	}
	if resBody.UserID != userID {
		t.Errorf("expected comment user id to be %v but got %v", userID, resBody.UserID)
	}
	if resBody.RecordID != recordID {
		t.Errorf("expected comment record id to be %v but got %v", recordID, resBody.RecordID)
	}
	if resBody.UserName != comment.UserName {
		t.Errorf("expected comment user name to be %v but got %v", comment.UserName, resBody.UserName)
	}
	if resBody.Text != comment.Text {
		t.Errorf("expected comment text to be %v but got %v", comment.Text, resBody.Text)
	}
}

func TestUpdateComment(t *testing.T) {
	comment := Comment{
		UserID:   1,
		RecordID: 1,
		UserName: "Tester",
		Text:     "FFFFFFFFFFF",
	}
	if err := DB.Create(&comment).Error; err != nil {
		t.Fatalf("failed to create test user: %v", err)
	}

	updatedComment := Comment{
		ID:       comment.ID,
		UserID:   1,
		RecordID: 1,
		UserName: "Update Tester",
		Text:     "GGGGGGGGGGGG",
	}
	reqBody, err := json.Marshal(updatedComment)
	if err != nil {
		t.Fatalf("failed to marshal request body: %v", err)
	}

	router := NewRouter()
	req := httptest.NewRequest(http.MethodPatch, "/api/users/"+strconv.Itoa(int(comment.UserID))+"/records/"+strconv.Itoa(int(comment.RecordID))+"/comments/"+strconv.Itoa(int(comment.ID)), bytes.NewBuffer(reqBody))
	req.Header.Set("Content-Type", "application/json")
	res := httptest.NewRecorder()
	router.ServeHTTP(res, req)

	if res.Code != http.StatusCreated {
		t.Errorf("expected status code %v but got %v", http.StatusCreated, res.Code)
	}

	var resBody Comment
	if err := json.Unmarshal(res.Body.Bytes(), &resBody); err != nil {
		t.Fatalf("failed to unmarshal response body: %v", err)
	}
	if resBody.UserID != updatedComment.UserID {
		t.Errorf("expected comment user id to be %v but got %v", updatedComment.UserID, resBody.UserID)
	}
	if resBody.UserID != updatedComment.RecordID {
		t.Errorf("expected comment record id to be %v but got %v", updatedComment.RecordID, resBody.UserID)
	}
	if resBody.UserName != updatedComment.UserName {
		t.Errorf("expected comment user name to be %v but got %v", updatedComment.UserName, resBody.UserName)
	}
	if resBody.Text != updatedComment.Text {
		t.Errorf("expected comment text to be %v but got %v", updatedComment.Text, resBody.Text)
	}
}

func TestDeleteComment(t *testing.T) {
	router := NewRouter()
	req := httptest.NewRequest(http.MethodDelete, "/api/users/1/records/1/comments/1", nil)
	res := httptest.NewRecorder()
	router.ServeHTTP(res, req)

	if res.Code != http.StatusNoContent {
		t.Errorf("expected status code %v, but got %v", http.StatusNoContent, res.Code)
	}

	var deletedComment *Comment
	err := DB.First(&deletedComment, "1").Error
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		t.Errorf("expected spot record to be deleted, but found: %v", deletedComment)
	}
}

実行方法

実行方法はAPIの実行テストの実行を参照してください。

OPEN API

今回作成しているAPIです。

おわりに

今回はGolangでCommentのREST APIを作成しました。
実装最後の次回はBookmarkのAPIを実装していこうと思います。

はじめてのGolangのAPI作成の役に立てれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?