はじめに
前回の続きで、APIの詳細の実装をしていきます。
その中でも今回はRecord周りを記事にしていきます。
過去の記事はこちら
Go言語でREST APIを作ってみる【Echo + GORM + MySQL】
Go言語でREST APIを作ってみる②(User)【Echo + GORM + MySQL】
Go言語でREST APIを作ってみる③(Spot)【Echo + GORM + MySQL】
ディレクトリ構成
今回使用するファイルは以下です。
.
├── handler
│ └── record.go
├── model
│ └── record.go
├── repository
│ └── db.go
├── router
│ └── router.go
├── test
├── .env
│ └── record_test.go
├── .env
└── main.go
実装
Record周りの実装をしていきます。
モデルの作成
model/record.go
package model
import "time"
type Record struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SpotID int64 `json:"spot_id"`
Date string `json:"date"`
Weather string `json:"weather"`
Temperature float32 `json:"temperature"`
RunningTime float32 `json:"running_time"`
Distance float32 `json:"distance"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
DBの作成
過去に作成したrepository/db.goの追記していきます。
repository/db.go
package repository
import (
"log"
"os"
. "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.AutoMigrate(&User{})
DB.AutoMigrate(&Spot{})
DB.AutoMigrate(&Record{}) // 追記
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)
}
DBの細かな解説は前回の記事を参照してください
Router
router.go
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)
}
Handler
handler/record.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 GetUserRecords(c echo.Context) error {
records := []Record{}
userID := c.Param("user_id")
if userID == "" {
return c.JSON(http.StatusBadRequest, "user ID is required")
}
if err := DB.Where("user_id = ?", userID).Find(&records).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, "records not found")
}
return err
}
return c.JSON(http.StatusOK, records)
}
func GetSpotRecords(c echo.Context) error {
records := []Record{}
spotID := c.Param("spot_id")
if spotID == "" {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
if err := DB.Where("spot_id = ?", spotID).Find(&records).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, "records not found")
}
return err
}
return c.JSON(http.StatusOK, records)
}
func GetRecord(c echo.Context) error {
record := []Record{}
recordID := c.Param("record_id")
if recordID == "" {
return c.JSON(http.StatusBadRequest, "record ID is required")
}
if err := DB.Where("id = ?", recordID).Find(&record).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, "record not found")
}
return err
}
return c.JSON(http.StatusOK, record)
}
func CreateRecord(c echo.Context) error {
record := Record{}
if err := c.Bind(&record); 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")
}
spotID, _ := strconv.ParseInt(c.Param("spot_id"), 10, 64)
if spotID == 0 {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
record.UserID = userID
record.SpotID = spotID
DB.Create(&record)
return c.JSON(http.StatusCreated, record)
}
func UpdateRecord(c echo.Context) error {
record := new(Record)
userID, _ := strconv.ParseInt(c.Param("user_id"), 10, 64)
if userID == 0 {
return c.JSON(http.StatusBadRequest, "user ID is required")
}
spotID, _ := strconv.ParseInt(c.Param("spot_id"), 10, 64)
if spotID == 0 {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
recordID := c.Param("record_id")
if recordID == "" {
return c.JSON(http.StatusBadRequest, "record ID is required")
}
if err := DB.First(&record, recordID).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
if record.UserID != userID {
return c.JSON(http.StatusBadRequest, "user and record do not match")
}
if record.SpotID != spotID {
return c.JSON(http.StatusBadRequest, "spot and record do not match")
}
if err := c.Bind(&record); err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
if err := DB.Model(&record).Where("id=?", record.ID).Updates(&record).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusCreated, record)
}
func DeleteRecord(c echo.Context) error {
record := new(Record)
userID, _ := strconv.ParseInt(c.Param("user_id"), 10, 64)
if userID == 0 {
return c.JSON(http.StatusBadRequest, "user ID is required")
}
spotID, _ := strconv.ParseInt(c.Param("spot_id"), 10, 64)
if spotID == 0 {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
recordID := c.Param("record_id")
if recordID == "" {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
if err := DB.First(&record, recordID).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
if record.UserID != userID {
return c.JSON(http.StatusBadRequest, "user and record do not match")
}
if record.SpotID != spotID {
return c.JSON(http.StatusBadRequest, "spot and record do not match")
}
if err := DB.Where("id = ?", recordID).Delete(&record).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusNoContent, record)
}
テスト
test/record_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 TestGetUserRecords(t *testing.T) {
router := NewRouter()
req := httptest.NewRequest(http.MethodGet, "/api/users/1/records", 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,"spot_id":1,"date":"70710-05-05","weather":"晴れ","temperature":23.4,"running_time":4,"distance":120.4,"description":"最高のツーリング日和でした!"`
expectedBody2 := `"id":2,"user_id":1,"spot_id":2,"date":"70710-05-05","weather":"曇り","temperature":26.1,"running_time":7,"distance":184.1,"description":"なんとか天気が持って良かったです!"`
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 TestGetSpotRecords(t *testing.T) {
router := NewRouter()
req := httptest.NewRequest(http.MethodGet, "/api/spots/1/records", 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,"spot_id":1,"date":"70710-05-05","weather":"晴れ","temperature":23.4,"running_time":4,"distance":120.4,"description":"最高のツーリング日和でした!"`
expectedBody2 := `"id":3,"user_id":2,"spot_id":1,"date":"70710-05-05","weather":"雨","temperature":13.4,"running_time":2,"distance":50.6,"description":"朝から雨で大変でした。"`
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 TestGetRecord(t *testing.T) {
router := NewRouter()
req := httptest.NewRequest(http.MethodGet, "/api/records/1", 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,"spot_id":1,"date":"70710-05-05","weather":"晴れ","temperature":23.4,"running_time":4,"distance":120.4,"description":"最高のツーリング日和でした!"`
if !strings.Contains(res.Body.String(), expectedBody) {
t.Errorf("unexpected response body: got %v, want %v", res.Body.String(), expectedBody)
}
}
func TestCreateRecord(t *testing.T) {
router := NewRouter()
record := Record{
Date: "2023-01-01",
Weather: "曇り",
Temperature: 23.4,
RunningTime: 4.5,
Distance: 201.6,
Description: "AAAAAAAAAAAAAA",
}
reqBody, err := json.Marshal(record)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
var userID int64 = 1
var spotID int64 = 1
req := httptest.NewRequest(http.MethodPost, "/api/users/"+strconv.Itoa(int(userID))+"/spots/"+strconv.Itoa(int(spotID))+"/records", 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 Record
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 record user id to be %v but got %v", userID, resBody.UserID)
}
if resBody.SpotID != spotID {
t.Errorf("expected record record id to be %v but got %v", spotID, resBody.SpotID)
}
if resBody.Date != record.Date {
t.Errorf("expected record date to be %v but got %v", record.Date, resBody.Date)
}
if resBody.Weather != record.Weather {
t.Errorf("expected record Weather to be %v but got %v", record.Weather, resBody.Weather)
}
if resBody.Temperature != record.Temperature {
t.Errorf("expected record Temperature to be %v but got %v", record.Temperature, resBody.Temperature)
}
if resBody.RunningTime != record.RunningTime {
t.Errorf("expected record RunningTime to be %v but got %v", record.RunningTime, resBody.RunningTime)
}
if resBody.Distance != record.Distance {
t.Errorf("expected record Distance to be %v but got %v", record.Distance, resBody.Distance)
}
if resBody.Description != record.Description {
t.Errorf("expected record Description to be %v but got %v", record.Description, resBody.Description)
}
}
func TestUpdateRecord(t *testing.T) {
record := Record{
UserID: 1,
SpotID: 1,
Date: "2023-01-01",
Weather: "曇り",
Temperature: 23.4,
RunningTime: 4.5,
Distance: 201.6,
Description: "AAAAAAAAAAAAAA",
}
if err := DB.Create(&record).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
updatedRecord := Record{
ID: record.ID,
UserID: 1,
SpotID: 1,
Date: "2023-02-01",
Weather: "晴れ",
Temperature: 33.4,
RunningTime: 6.5,
Distance: 211.5,
Description: "BBBBBBBBBBBBB",
}
reqBody, err := json.Marshal(updatedRecord)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
router := NewRouter()
req := httptest.NewRequest(http.MethodPatch, "/api/users/"+strconv.Itoa(int(record.UserID))+"/spots/"+strconv.Itoa(int(record.SpotID))+"/records/"+strconv.Itoa(int(record.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 Record
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 != updatedRecord.UserID {
t.Errorf("expected record user id to be %v but got %v", updatedRecord.UserID, resBody.UserID)
}
if resBody.SpotID != updatedRecord.SpotID {
t.Errorf("expected record record id to be %v but got %v", updatedRecord.SpotID, resBody.SpotID)
}
if resBody.Date != updatedRecord.Date {
t.Errorf("expected record date to be %v but got %v", updatedRecord.Date, resBody.Date)
}
if resBody.Weather != updatedRecord.Weather {
t.Errorf("expected record Weather to be %v but got %v", updatedRecord.Weather, resBody.Weather)
}
if resBody.Temperature != updatedRecord.Temperature {
t.Errorf("expected record Temperature to be %v but got %v", updatedRecord.Temperature, resBody.Temperature)
}
if resBody.RunningTime != updatedRecord.RunningTime {
t.Errorf("expected record RunningTime to be %v but got %v", updatedRecord.RunningTime, resBody.RunningTime)
}
if resBody.Distance != updatedRecord.Distance {
t.Errorf("expected record Distance to be %v but got %v", updatedRecord.Distance, resBody.Distance)
}
if resBody.Description != updatedRecord.Description {
t.Errorf("expected record Description to be %v but got %v", updatedRecord.Description, resBody.Description)
}
}
func TestDeleteRecord(t *testing.T) {
router := NewRouter()
req := httptest.NewRequest(http.MethodDelete, "/api/users/1/spots/1/records/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 deletedRecord *Record
err := DB.First(&deletedRecord, "1").Error
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected record to be deleted, but found: %v", deletedRecord)
}
}
おわりに
今回はGolangでRecordのREST APIを作成しました。
次回はCommentのAPIを実装していこうと思います。
はじめてのGolangのAPI作成の役に立てれば幸いです。