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

Last updated at Posted at 2023-05-01

はじめに

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

前回の記事はこちら

ディレクトリ構成

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

.
├── handler
│   └── user.go
├── model
│   └── user.go
├── repository
│   └── db.go
├── router
│   └── router.go
├── test
    ├── .env
│   └── user_test.go
├── .env
└── main.go

実装

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

モデルの作成

model/user.go
package model

import "time"

type User struct {
	ID         int64     `json:"id"`
	Name       string    `json:"name"`
	Email      string    `json:"email"`
	Password   string    `json:"password"`
	Area       string    `json:"area"`
	Prefecture string    `json:"prefecture"`
	Url        string    `json:"url"`
	BikeName   string    `json:"bike_name"`
	Experience int8      `json:"experience"`
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
	Spots      []Spot    `json:"posts" gorm:"foreignKey:UserID;constraint:OnDelete:SET NULL" param:"user_id"`
}

DBの作成

前回作成したrepository/db.goの追記していきます。

db.go
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.AutoMigrate(&User{})

	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)
}

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")
}

データソースを実行環境ごとに分けるために分岐しています。
本来は.envを一つにまとめたいのですが、うまく分けられていないのでtestディレクトリにも配置しています。
良い方法があれば教えて下さい。

これでテストのときはテスト用のDBを使用することができます。

DB.Migrator().DropTable(&User{})

毎回テーブルを削除しています。
本番環境ではもちろんあってはいけないです。
しかし、今回は後述する開発用にSeedデータを作成しているためわざと削除しています。

DB.AutoMigrate(&User{})

Userテーブルを作成。

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)

Seedデータを追加、レコードの作成。
開発環境でAPIを叩く際にデータがある方が何かと便利なのでここで事前にデータを作成しています。

これでテーブルが作成されて、データが挿入されていればOKです。

Router

次にrouter.goを追加していきます。
Railsで言うとルーティングに当たるものです。
localhost:8080/api/v1/hogeのようにリクエストをしたときに、何をするのかを記載していきます。

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)

	return e
}

前回記載したopenapiの仕様書に沿って必要なルーティングを設定します。
今回は基本的なUserCRUDです。

e.GET("/api/users", handler.GetUsers)

handler.GetUsersの部分の詳細は後述しますが、/api/usersを言うpathが呼ばれた際にhandler.GetUsersを返すということを記載しています。

handlerはRailsで言うとcontrollerみたいなものだと思ってください。

main.go

作成したpathを使用できるようにするためにサーバーを起動するとrouterが読まれるようにしておきます。

main.go
func main() {
	db, _ := rep.DB.DB()
	defer db.Close()

	e := router.NewRouter()

	e.Logger.Fatal(e.Start(":8080"))
}
e := router.NewRouter()

先程作成したrouterを読んでいる。

Handler

UserのAPI処理を実装していきます。
Railsのcontrollerのような役割だと思ってもらえるとわかりやすいと思います。

handler/user.go
package handler

import (
	"errors"
	"net/http"

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

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

func GetUsers(c echo.Context) error {
	users := []User{}

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

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

func GetUser(c echo.Context) error {
	user := User{}

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

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

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

func CreateUser(c echo.Context) error {
	user := User{}

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

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

func UpdateUser(c echo.Context) error {
	user := new(User)

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

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

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

	if err := DB.Model(&user).Updates(&user).Error; err != nil {
		return c.JSON(http.StatusBadRequest, err.Error())
	}

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

func DeleteUser(c echo.Context) error {
	user := new(User)

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

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

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

少し余談ですがRailsとはもちろん違った書き方をするのですが、なんだかRailsとJSを合わせたような書き方なのかな〜と思ったりもしました。(早期リターンをしたりする雰囲気が、、、)

実行

ここまで実装できれば作成したAPIを実行してみます。

make up

# GetUsers
curl -X 'GET' \
  'http://localhost:8080/api/users' \
  -H 'accept: application/json'

# GetUser
curl -X 'GET' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: application/json'

# PostUser
curl -X 'POST' \
  'http://localhost:8080/api/users' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "string",
  "password": "string",
  "name": "string",
  "area": "string",
  "prefecture": "string",
  "url": "string",
  "bike_name": "string",
  "experience": 0
}'

# UpdateUser
curl -X 'PATCH' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "string",
  "password": "string",
  "name": "string",
  "area": "string",
  "prefecture": "string",
  "url": "string",
  "bike_name": "string",
  "experience": 0
}'

# DeleteUser
curl -X 'DELETE' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: */*'

それぞれopenapiで定義しているレスポンスが得られたらOKです。

テスト

最後にテストをしておきます。

test/user_test.go
package test

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

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

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

func TestGetUsers(t *testing.T) {
	e := echo.New()
	req := httptest.NewRequest(http.MethodGet, "/api/users", nil)
	res := httptest.NewRecorder()
	c := e.NewContext(req, res)
	err := GetUsers(c)

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

	expectedBody := `"id":1,"name":"tester1","email":"tester1@bike_noritai_dev","password":"password","area":"東海","prefecture":"三重県","url":"http://test.com","bike_name":"CBR650R","experience":5`

	expectedBody2 := `"id":2,"name":"tester2","email":"tester2@bike_noritai_dev","password":"password","area":"関東","prefecture":"東京都","url":"http://test.com","bike_name":"CBR1000RR","experience":10`

	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 TestGetUser(t *testing.T) {
	router := NewRouter()
	req := httptest.NewRequest(http.MethodGet, "/api/users/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,"name":"tester1","email":"tester1@bike_noritai_dev","password":"password","area":"東海","prefecture":"三重県","url":"http://test.com","bike_name":"CBR650R","experience":5`

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

func TestCreateUser(t *testing.T) {
	e := echo.New()

	user := User{
		Name:       "tester3",
		Email:      "tester3@bike_noritai_dev.com",
		Password:   "password",
		Area:       "関西",
		Prefecture: "大阪",
		Url:        "",
		BikeName:   "Ninja650",
		Experience: 10,
	}
	reqBody, err := json.Marshal(user)
	if err != nil {
		t.Fatalf("failed to marshal request body: %v", err)
	}
	req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBuffer(reqBody))
	req.Header.Set("Content-Type", "application/json")
	res := httptest.NewRecorder()
	c := e.NewContext(req, res)

	if err := CreateUser(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 User
	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 user ID to be non-zero but got %v", resBody.ID)
	}
	if resBody.Name != user.Name {
		t.Errorf("expected user name to be %v but got %v", user.Name, resBody.Name)
	}
	if resBody.Email != user.Email {
		t.Errorf("expected user email to be %v but got %v", user.Email, resBody.Email)
	}
	if resBody.Password != user.Password {
		t.Errorf("expected user password to be %v but got %v", user.Password, resBody.Password)
	}
	if resBody.Area != user.Area {
		t.Errorf("expected user area to be %v but got %v", user.Area, resBody.Area)
	}
	if resBody.Prefecture != user.Prefecture {
		t.Errorf("expected user prefecture to be %v but got %v", user.Prefecture, resBody.Prefecture)
	}
	if resBody.Url != user.Url {
		t.Errorf("expected user url to be %v but got %v", user.Url, resBody.Url)
	}
	if resBody.BikeName != user.BikeName {
		t.Errorf("expected user bike name to be %v but got %v", user.BikeName, resBody.BikeName)
	}
	if resBody.Experience != user.Experience {
		t.Errorf("expected user experience to be %v but got %v", user.Experience, resBody.Experience)
	}
}

func TestUpdateUser(t *testing.T) {
	e := echo.New()

	user := User{
		Name:       "John Doe",
		Email:      "john.doe@example.com",
		Password:   "password",
		Area:       "関西",
		Prefecture: "大阪",
		Url:        "",
		BikeName:   "Ninja650",
		Experience: 10,
	}
	if err := DB.Create(&user).Error; err != nil {
		t.Fatalf("failed to create test user: %v", err)
	}

	updatedUser := User{
		ID:         user.ID,
		Name:       "Jane Smith",
		Email:      "jane.smith@example.com",
		Password:   "update_password",
		Area:       "九州",
		Prefecture: "福岡",
		Url:        "https://example.com",
		BikeName:   "R6",
		Experience: 16,
	}
	reqBody, err := json.Marshal(updatedUser)
	if err != nil {
		t.Fatalf("failed to marshal request body: %v", err)
	}
	req := httptest.NewRequest(http.MethodPut, "/api/users/"+strconv.Itoa(int(user.ID)), bytes.NewBuffer(reqBody))
	req.Header.Set("Content-Type", "application/json")
	res := httptest.NewRecorder()
	c := e.NewContext(req, res)
	c.SetParamNames("user_id")
	c.SetParamValues(strconv.Itoa(int(user.ID)))

	if err := UpdateUser(c); err != nil {
		t.Fatalf("failed to update user: %v", err)
	}

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

	var resBody User
	if err := json.Unmarshal(res.Body.Bytes(), &resBody); err != nil {
		t.Fatalf("failed to unmarshal response body: %v", err)
	}
	if resBody.ID != user.ID {
		t.Errorf("expected user ID to be %v but got %v", user.ID, resBody.ID)
	}
	if resBody.Name != updatedUser.Name {
		t.Errorf("expected user name to be %v but got %v", updatedUser.Name, resBody.Name)
	}
	if resBody.Email != updatedUser.Email {
		t.Errorf("expected user email to be %v but got %v", updatedUser.Email, resBody.Email)
	}
	if resBody.Password != updatedUser.Password {
		t.Errorf("expected user password to be %v but got %v", updatedUser.Password, resBody.Password)
	}
	if resBody.Area != updatedUser.Area {
		t.Errorf("expected user area to be %v but got %v", updatedUser.Area, resBody.Area)
	}
	if resBody.Prefecture != updatedUser.Prefecture {
		t.Errorf("expected user prefecture to be %v but got %v", updatedUser.Prefecture, resBody.Prefecture)
	}
	if resBody.Url != updatedUser.Url {
		t.Errorf("expected user url to be %v but got %v", updatedUser.Url, resBody.Url)
	}
	if resBody.BikeName != updatedUser.BikeName {
		t.Errorf("expected user bike_name to be %v but got %v", updatedUser.BikeName, resBody.BikeName)
	}
	if resBody.Experience != updatedUser.Experience {
		t.Errorf("expected user experience to be %v but got %v", updatedUser.Experience, resBody.Experience)
	}
}

func TestDeleteUser(t *testing.T) {
	router := NewRouter()
	req := httptest.NewRequest(http.MethodDelete, "/api/users/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 deletedUser User
	err := DB.First(&deletedUser, "1").Error
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		t.Errorf("expected user record to be deleted, but found: %v", deletedUser)
	}
}

テストの書き方も色々をあるようですがまずは初歩的な記述方法にしています。

実行

make test_db

make go_test

テストが事項されすべてPASSすればOKです。

おわりに

今回はGolangでUserのREST APIを作成しました。
一つやり方が決まると他のモデルやハンドラーに転用できるため次はSpotあたりを実装していこうと思います。
はじめての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?