0
1

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を作ってみる③(Spot)【Echo + GORM + MySQL】

Last updated at Posted at 2023-05-04

はじめに

今回は前回の続きで、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作成の役に立てれば幸いです。

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?