はじめに
今回は前回の続きで、APIの詳細の実装をしていきます。
その中でもSpot周りを記事にしていきます。
過去の記事はこちら
Go言語でREST APIを作ってみる【Echo + GORM + MySQL】
Go言語でREST APIを作ってみる②(User)【Echo + GORM + MySQL】
ディレクトリ構成
今回使用するファイルは以下です。
.
├── handler
│ └── spot.go
├── model
│ └── spot.go
├── repository
│ └── db.go
├── router
│ └── router.go
├── test
├── .env
│ └── spot_test.go
├── .env
└── main.go
実装
Spot周りの実装をしていきます。
モデルの作成
model/spot.go
package model
import "time"
type Spot struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
Image string `json:"image"`
Type string `json:"type"`
Address string `json:"address"`
HpURL string `json:"hp_url"`
OpenTime string `json:"open_time"`
OffDay string `json:"off_day"`
Parking bool `json:"parking"`
Description string `json:"description"`
Lat float32 `json:"lat"`
Lng float32 `json:"lng"`
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.AutoMigrate(&User{})
DB.AutoMigrate(&Spot{}) // 追記
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)
}
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/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)
}
Handler
handler/spot.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 GetSpots(c echo.Context) error {
spots := []Spot{}
if err := DB.Find(&spots).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, "spots not found")
}
return err
}
return c.JSON(http.StatusOK, spots)
}
func GetSpot(c echo.Context) error {
spot := Spot{}
spotID := c.Param("spot_id")
if spotID == "" {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
if err := DB.First(&spot, spotID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, "spot not found")
}
return err
}
return c.JSON(http.StatusOK, spot)
}
func GetUserSpot(c echo.Context) error {
spots := []Spot{}
userID := c.Param("user_id")
if userID == "" {
return c.JSON(http.StatusBadRequest, "user ID is required")
}
if err := DB.Where("user_id = ?", userID).Find(&spots).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, "spot not found")
}
return err
}
return c.JSON(http.StatusOK, spots)
}
func CreateSpot(c echo.Context) error {
spot := Spot{}
if err := c.Bind(&spot); err != nil {
return err
}
DB.Create(&spot)
return c.JSON(http.StatusCreated, spot)
}
func UpdateSpot(c echo.Context) error {
spot := new(Spot)
userID, _ := strconv.ParseInt(c.Param("user_id"), 10, 64)
if userID == 0 {
return c.JSON(http.StatusBadRequest, "user ID is required")
}
spotID := c.Param("spot_id")
if spotID == "" {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
if err := DB.First(&spot, spotID).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
if spot.UserID != userID {
return c.JSON(http.StatusBadRequest, "user and spot do not match")
}
if err := c.Bind(&spot); err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
if err := DB.Model(&spot).Where("id=?", spot.ID).Updates(&spot).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusCreated, spot)
}
func DeleteSpot(c echo.Context) error {
spot := new(Spot)
userID, _ := strconv.ParseInt(c.Param("user_id"), 10, 64)
if userID == 0 {
return c.JSON(http.StatusBadRequest, "user ID is required")
}
spotID := c.Param("spot_id")
if spotID == "" {
return c.JSON(http.StatusBadRequest, "spot ID is required")
}
if err := DB.First(&spot, spotID).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
if spot.UserID != userID {
return c.JSON(http.StatusBadRequest, "user and spot do not match")
}
if err := DB.Where("id = ?", spotID).Delete(&spot).Error; err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusNoContent, spot)
}
テスト
test/spot_test.go
package test
import (
"bytes"
"encoding/json"
"errors"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
. "bike_noritai_api/handler"
. "bike_noritai_api/model"
. "bike_noritai_api/repository"
. "bike_noritai_api/router"
)
func TestGetSpots(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/spots", nil)
res := httptest.NewRecorder()
c := e.NewContext(req, res)
err := GetSpots(c)
if res.Code != http.StatusOK {
t.Errorf("unexpected status code: got %v, want %v", res.Code, http.StatusOK)
}
expectedBody := `"id":1,"user_id":1,"name":"豊受大神宮 (伊勢神宮 外宮)","image":"http://test.com","type":"観光","address":"三重県伊勢市豊川町279","hp_url":"https://www.isejingu.or.jp/about/geku/","open_time":"5:00~18:00","off_day":"","parking":true,"description":"外宮から行くのが良いみたいですよ。","lat":34.4879,"lng":136.704`
expectedBody2 := `"id":2,"user_id":1,"name":"伊勢神宮(内宮)","image":"http://test.com","type":"観光","address":"三重県伊勢市宇治館町1","hp_url":"https://www.isejingu.or.jp/","open_time":"5:00~18:00","off_day":"","parking":true,"description":"日本最大の由緒正しき神社です。","lat":34.4562,"lng":136.726`
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)
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestGetSpot(t *testing.T) {
router := NewRouter()
req := httptest.NewRequest(http.MethodGet, "/api/spots/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,"name":"豊受大神宮 (伊勢神宮 外宮)","image":"http://test.com","type":"観光","address":"三重県伊勢市豊川町279","hp_url":"https://www.isejingu.or.jp/about/geku/","open_time":"5:00~18:00","off_day":"","parking":true,"description":"外宮から行くのが良いみたいですよ。","lat":34.487865,"lng":136.70374`
if !strings.Contains(res.Body.String(), expectedBody) {
t.Errorf("unexpected response body: got %v, want %v", res.Body.String(), expectedBody)
}
}
func TestGetUserSpot(t *testing.T) {
spot := Spot{
UserID: 2,
Name: "東京スカイツリー",
Image: "http://test.com",
Type: "観光",
Address: "〒131-0045 東京都墨田区押上1丁目1−2",
HpURL: "https://www.tokyo-skytree.jp/",
OpenTime: "10:00~21:00",
OffDay: "",
Parking: true,
Description: "大林建設が施工した日本最高峰の電波塔です。",
Lat: 35.71021159216932,
Lng: 139.81076575474597,
}
if err := DB.Create(&spot).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
router := NewRouter()
req := httptest.NewRequest(http.MethodGet, "/api/users/2/spots", 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,"name":"東京スカイツリー","image":"http://test.com","type":"観光","address":"〒131-0045 東京都墨田区押上1丁目1−2","hp_url":"https://www.tokyo-skytree.jp/","open_time":"10:00~21:00","off_day":"","parking":true,"description":"大林建設が施工した日本最高峰の電波塔です。","lat":35.710213,"lng":139.81076`
if !strings.Contains(res.Body.String(), expectedBody) {
t.Errorf("unexpected response body: got %v, want %v", res.Body.String(), expectedBody)
}
}
func TestCreateSpot(t *testing.T) {
e := echo.New()
spot := Spot{
UserID: 1,
Name: "東京スカイツリー",
Image: "http://test.com",
Type: "観光",
Address: "〒131-0045 東京都墨田区押上1丁目1−2",
HpURL: "https://www.tokyo-skytree.jp/",
OpenTime: "10:00~21:00",
OffDay: "",
Parking: true,
Description: "大林建設が施工した日本最高峰の電波塔です。",
Lat: 35.71021159216932,
Lng: 139.81076575474597,
}
reqBody, err := json.Marshal(spot)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/users/1/spots", bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
c := e.NewContext(req, res)
if err := CreateSpot(c); err != nil {
t.Fatalf("failed to create user: %v", err)
}
if res.Code != http.StatusCreated {
t.Errorf("expected status code %v but got %v", http.StatusCreated, res.Code)
}
var resBody Spot
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 != spot.UserID {
t.Errorf("expected spot user id to be %v but got %v", spot.UserID, resBody.UserID)
}
if resBody.Name != spot.Name {
t.Errorf("expected spot name to be %v but got %v", spot.Name, resBody.Name)
}
if resBody.Image != spot.Image {
t.Errorf("expected spot image to be %v but got %v", spot.Image, resBody.Image)
}
if resBody.Type != spot.Type {
t.Errorf("expected spot type to be %v but got %v", spot.Type, resBody.Type)
}
if resBody.Address != spot.Address {
t.Errorf("expected spot address to be %v but got %v", spot.Address, resBody.Address)
}
if resBody.HpURL != spot.HpURL {
t.Errorf("expected spot HP URL to be %v but got %v", spot.HpURL, resBody.HpURL)
}
if resBody.OpenTime != spot.OpenTime {
t.Errorf("expected spot open time to be %v but got %v", spot.OpenTime, resBody.OpenTime)
}
if resBody.OffDay != spot.OffDay {
t.Errorf("expected spot off day to be %v but got %v", spot.OffDay, resBody.OffDay)
}
if resBody.Parking != spot.Parking {
t.Errorf("expected spot parking to be %v but got %v", spot.Parking, resBody.Parking)
}
if resBody.Description != spot.Description {
t.Errorf("expected spot description to be %v but got %v", spot.Description, resBody.Description)
}
if resBody.Lat != spot.Lat {
t.Errorf("expected spot lat to be %v but got %v", spot.Lat, resBody.Lat)
}
if resBody.Lng != spot.Lng {
t.Errorf("expected spot lng to be %v but got %v", spot.Lng, resBody.Lng)
}
}
func TestUpdateSpot(t *testing.T) {
spot := Spot{
UserID: 1,
Name: "東京スカイツリー",
Image: "http://test.com",
Type: "観光",
Address: "〒131-0045 東京都墨田区押上1丁目1−2",
HpURL: "https://www.tokyo-skytree.jp/",
OpenTime: "10:00~21:00",
OffDay: "",
Parking: true,
Description: "大林建設が施工した日本最高峰の電波塔です。",
Lat: 35.71021159216932,
Lng: 139.81076575474597,
}
if err := DB.Create(&spot).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
updatedSpot := Spot{
ID: spot.ID,
UserID: 1,
Name: "豊島美術館",
Image: "http://test.com",
Type: "観光",
Address: "〒761-4662 香川県小豆郡土庄町豊島唐櫃607",
HpURL: "https://benesse-artsite.jp/art/teshima-artmuseum.html",
OpenTime: "9:00~17:00",
OffDay: "",
Parking: true,
Description: "安藤忠雄が設計したユニークな美術館です。",
Lat: 34.49158555200611,
Lng: 134.09277913086976,
}
reqBody, err := json.Marshal(updatedSpot)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
router := NewRouter()
req := httptest.NewRequest(http.MethodPatch, "/api/users/"+strconv.Itoa(int(spot.UserID))+"/spots/"+strconv.Itoa(int(spot.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 Spot
if err := json.Unmarshal(res.Body.Bytes(), &resBody); err != nil {
t.Fatalf("failed to unmarshal response body: %v", err)
}
if resBody.ID != spot.ID {
t.Errorf("expected spot ID to be %v but got %v", spot.ID, resBody.ID)
}
if resBody.UserID != updatedSpot.UserID {
t.Errorf("expected spot user_id to be %v but got %v", updatedSpot.UserID, resBody.UserID)
}
if resBody.Name != updatedSpot.Name {
t.Errorf("expected spot name to be %v but got %v", updatedSpot.Name, resBody.Name)
}
if resBody.Image != updatedSpot.Image {
t.Errorf("expected spot Image to be %v but got %v", updatedSpot.Image, resBody.Image)
}
if resBody.Type != updatedSpot.Type {
t.Errorf("expected spot Type to be %v but got %v", updatedSpot.Type, resBody.Type)
}
if resBody.Address != updatedSpot.Address {
t.Errorf("expected spot Address to be %v but got %v", updatedSpot.Address, resBody.Address)
}
if resBody.HpURL != updatedSpot.HpURL {
t.Errorf("expected spot HpURL to be %v but got %v", updatedSpot.HpURL, resBody.HpURL)
}
if resBody.OpenTime != updatedSpot.OpenTime {
t.Errorf("expected spot OpenTime to be %v but got %v", updatedSpot.OpenTime, resBody.OpenTime)
}
if resBody.OffDay != updatedSpot.OffDay {
t.Errorf("expected spot OffDay to be %v but got %v", updatedSpot.OffDay, resBody.OffDay)
}
if resBody.Parking != updatedSpot.Parking {
t.Errorf("expected spot Parking to be %v but got %v", updatedSpot.Parking, resBody.Parking)
}
if resBody.Description != updatedSpot.Description {
t.Errorf("expected spot Description to be %v but got %v", updatedSpot.Description, resBody.Description)
}
if resBody.Lat != updatedSpot.Lat {
t.Errorf("expected spot Lat to be %v but got %v", updatedSpot.Lat, resBody.Lat)
}
if resBody.Lng != updatedSpot.Lng {
t.Errorf("expected spot Lng to be %v but got %v", updatedSpot.Lng, resBody.Lng)
}
}
func TestDeleteSpot(t *testing.T) {
router := NewRouter()
req := httptest.NewRequest(http.MethodDelete, "/api/users/1/spots/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 deletedSpot Spot
err := DB.First(&deletedSpot, "1").Error
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected spot record to be deleted, but found: %v", deletedSpot)
}
}
おわりに
今回はGolangでSpotのREST APIを作成しました。
次回はRecordのAPIを実装していこうと思います。
はじめてのGolangのAPI作成の役に立てれば幸いです。