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

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

**過去の記事はこちら**
[Go言語でREST APIを作ってみる【Echo + GORM + MySQL】](https://qiita.com/rorrou25/items/935ba8c09c74b2ada629)
[Go言語でREST APIを作ってみる②（User）【Echo + GORM + MySQL】](https://qiita.com/rorrou25/items/9ee6a0590b70db0ed0cb)
[Go言語でREST APIを作ってみる③（Spot）【Echo + GORM + MySQL】](https://qiita.com/rorrou25/items/8f99b47a9e94b72ff1e2)
[Go言語でREST APIを作ってみる④（Record:記録）【Echo + GORM + MySQL】](https://qiita.com/rorrou25/items/51ea0d050c9ceb96c2ba)

# ディレクトリ構成
今回使用するファイルは以下です。
```
.
├── 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:     "三重県伊勢市豊川町２７９",
			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:     "三重県伊勢市宇治館町１",
			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の細かな解説は[前回の記事](https://qiita.com/rorrou25/items/9ee6a0590b70db0ed0cb#db%E3%81%AE%E4%BD%9C%E6%88%90)を参照してください

## 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の実行](https://qiita.com/rorrou25/items/9ee6a0590b70db0ed0cb#%E5%AE%9F%E8%A1%8C)、[テストの実行](https://qiita.com/rorrou25/items/9ee6a0590b70db0ed0cb#%E5%AE%9F%E8%A1%8C-1)を参照してください。

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

https://app.swaggerhub.com/apis/MINAGAWA/BikeNoritai/1.0.0#/default/SpotShow


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

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