はじめに
今回は前回の続きで、APIの詳細の実装をしていきます。
その中でもUser周りを記事にしていきます。
前回の記事はこちら
ディレクトリ構成
今回使用するファイルは以下です。
.
├── handler
│ └── user.go
├── model
│ └── user.go
├── repository
│ └── db.go
├── router
│ └── router.go
├── test
├── .env
│ └── user_test.go
├── .env
└── main.go
実装
User周りの実装をしていきます。
モデルの作成
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
の追記していきます。
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
のようにリクエストをしたときに、何をするのかを記載していきます。
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が読まれるようにしておきます。
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のような役割だと思ってもらえるとわかりやすいと思います。
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です。
テスト
最後にテストをしておきます。
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作成の役に立てれば幸いです。