Go Echo APIサーバーの開発
以下の機能の最小構成のスタートプロジェクトを基に簡単なapiサーバーを作る
・sql-migrateによるdbマイグレーション
・gormによるアプリからのdb操作
・go-playground/validatorによる入力チェック
・本番、開発環境ごとに設定ファイルの切替え
・ユーザー認証のミドルウェア
また、ユーザー認証にはFirebase Authenticationを、データベースはMySQLを使うことを想定
作るもの
ブログの記事一覧取得するAPIと投稿した記事を更新するAPIの2つ。記事の一覧APIには誰でもアクセス可能で、記事の更新APIは投稿した本人のみがアクセス可能なものとする
前準備
セットアップ
ベースプロジェクトをクローン
$ git clone https://github.com/nrikiji/go-echo-sample
データベースの接続情報を環境に合わせて編集
development:
dialect: mysql
datasource: root:@tcp(localhost:3306)/go-echo-example?charset=utf8mb4&collation=utf8mb4_general_ci&parseTime=true
dir: migrations
table: migrations
・・・
postsテーブル作成
$ sql-migrate -config config.yml create_posts
$ vi migrations/xxxxx-create_posts.sql
-- +migrate Up
CREATE TABLE `posts` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL,
`title` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`body` text COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
$ sql-migrate up -env development -config config.yml
$ sql-migrate up -env test -config config.yml
また、usersテーブルのマイグレーションファイルはベースプロジェクトに含まれる(id、name、firebase_uidのみのシンプルなテーブル)
ダミーデータ登録
firebaseコンソールよりメールアドレス+パスワード認証のユーザーの作成と、WEB用のAPIキーを取得しておく(プロジェクトの設定 > 全般 の ウェブ API キー)
また、Firebase Admin SDKを使うための秘密鍵(プロジェクトの設定 > サービスアカウント の Firebase Admin SDK)をプロジェクトのルートに追加しておく。ここではfirebase_secret_key.jsonというファイル名とする
APIより登録したユーザのlocalId(FirebaseユーザーID)とidTokenを取得する。localIdをusers.firebase_uidにセット、idTokenはAPIにリクエストする際にhttpヘッダーにセットして使うことを想定
今回はfirebaseのログインAPIへ直接リクエストしてidTokenとlocalIdを取得
$ curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=APIキー' \
-H 'Content-Type: application/json' \
-d '{"email":"foo@example.com","password":"password","returnSecureToken":true}' | jq
{
"localId": "xxxxxxxxxxx",
"idToken": "xxxxxxxxxxxxxxxxxxxxxx",
・・・
}
取得したlocalIdでDBにユーザー登録
insert into users (id, firebase_uid, name, created_at, updated_at) values
(1, "xxxxxxxxxxx", "user1", now(), now());
insert into posts (user_id, title, body, created_at, updated_at) values
(1, "title1", "body1", now(), now()), (2, "title2", "body2", now(), now());
ここまでで開発の準備完了
データ操作部分の実装
DBから取得したレコードを表現するmodelを準備
package model
type Post struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `json:"user_id"`
User User `json:"user"`
Title string `json:"title"`
Body string `json:"body"`
}
gormを使用してDBから取得するメソッドと更新するメソッドをストアに追加する。ベースプロジェクトにUserStoreを用意してあるので、今回はここにAllPostsメソッドとUpdatePostメソッドを追加
package store
import (
"errors"
"go-echo-starter/model"
"gorm.io/gorm"
)
func (us *UserStore) AllPosts() ([]model.Post, error) {
var p []model.Post
err := us.db.Preload("User").Find(&p).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return p, nil
}
return nil, err
}
return p, nil
}
func (us *UserStore) UpdatePost(post *model.Post) error {
return us.db.Model(post).Updates(post).Error
}
取得APIの実装
リクエストされたらストアからモデルを取得してレスポンスをjsonで返す部分を実装する
実装
package handler
import (
"go-echo-starter/model"
"net/http"
"github.com/labstack/echo/v4"
)
type postsResponse struct {
Posts []model.Post `json:"posts"`
}
func (h *Handler) getPosts(c echo.Context) error {
posts, err := h.userStore.AllPosts()
if err != nil {
return err
}
return c.JSON(http.StatusOK, postsResponse{Posts: posts})
}
routesで/postsというパスでGETリクエストされたら作ったhandlerを呼び出すようにする
package handler
import (
"go-echo-starter/middleware"
"github.com/labstack/echo/v4"
)
func (h *Handler) Register(api *echo.Group) {
・・・
api.GET("/posts", h.getPosts)
}
動作確認
$ go run server.go
・・・
$ curl http://localhost:8000/api/posts | jq
{
"posts": [
{
"id": 1,
"user_id": 1,
"user": {
"id": 1,
"name": "user1",
},
"title": "title1",
"body": "body1",
},
・・・
}
testを書く
fixturesでテストデータを2件用意する
- id: 1
user_id: 1
title: "Title1"
body: "Body1"
- id: 2
user_id: 2
title: "Title2"
body: "Body2"
handlerのテストを書く。ここではエラーにならないことと件数が一致していることをテストする
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func TestGetPosts(t *testing.T) {
setup()
req := httptest.NewRequest(echo.GET, "/api/posts", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
assert.NoError(t, h.getPosts(c))
if assert.Equal(t, http.StatusOK, rec.Code) {
var res postsResponse
err := json.Unmarshal(rec.Body.Bytes(), &res)
assert.NoError(t, err)
assert.Equal(t, 2, len(res.Posts))
}
}
test実行
$ cd handler
$ go test -run TestGetPosts
・・・
ok go-echo-starter/handler 1.380s
更新APIの実装
他人の投稿を更新できないように認証用のauthミドルウェアを適用。このミドルウェアでしていることはhttpヘッダーAuthorization: Bearer xxxxx
にセットされているfirebaseのidTokenよりfirebaseのユーザーidを取得して、UIDをキーにusersテーブルを検索して結果をcontextにセットしている
handlerではcontextよりuserが取得できれば認証成功、できなければ認証失敗とする
user := context.Get("user")
if user == nil {
// 認証失敗
} else {
// 認証成功
}
実装
type postResponse struct {
Post model.Post `json:"post"`
}
type updatePostRequest struct {
Title string `json:"title" validate:"required"`
Body string `json:"body" validate:"required"`
}
func (h *Handler) updatePost(c echo.Context) error {
// ユーザー情報取得
u := c.Get("user")
if u == nil {
return c.JSON(http.StatusForbidden, nil)
}
user := u.(*model.User)
// 記事取得
id, _ := strconv.Atoi(c.Param("id"))
post, err := h.userStore.FindPostByID(id)
if err != nil {
return c.JSON(http.StatusInternalServerError, nil)
} else if post == nil {
return c.JSON(http.StatusNotFound, nil)
}
// 他人の記事なら不正アクセスとみなす
if post.UserID != user.ID {
return c.JSON(http.StatusForbidden, nil)
}
params := &updatePostRequest{}
if err := c.Bind(params); err != nil {
return c.JSON(http.StatusInternalServerError, nil)
}
// バリデーション
if err := c.Validate(params); err != nil {
return c.JSON(
http.StatusBadRequest,
ae.NewValidationError(err, ae.ValidationMessages{
"Title": {"required": "タイトルを入力してください"},
"Body": {"required": "本文を入力してください"},
}),
)
}
// データ更新
post.Title = params.Title
post.Body = params.Body
if err := h.userStore.UpdatePost(post); err != nil {
return c.JSON(http.StatusInternalServerError, nil)
}
return c.JSON(http.StatusOK, postResponse{Post: *post})
}
バリデーションはgo-playground/validatorのtranslations機能を使えば日本語にしてくれるがこのアプリではそれを使わずに、フィールド名とバリデーションルール名をキーとするmapを定義して固定なメッセージを表示するようにした
if err := c.Validate(params); err != nil {
return c.JSON(
http.StatusBadRequest,
ae.NewValidationError(err, ae.ValidationMessages{
"Title": {"required": "タイトルを入力してください"},
"Body": {"required": "本文を入力してください"},
}),
)
}
次に、routesで/postsというパスでPATCHリクエストされたら作ったhandlerを呼び出すようにする
func (h *Handler) Register(api *echo.Group) {
auth := middleware.AuthMiddleware(h.authStore, h.userStore)
・・・
api.PATCH("/posts/:id", h.updatePost, auth)
}
動作確認
上で取得したfirebaseのidTokenをhttpヘッダーにつけて動作確認
$ go run server.go
・・・
$ curl -X PATCH -H "Content-Type: application/json" \
-H "Authorization: Bearer xxxxxxxxxx" \
-d '{"title":"NewTitle","body":"NewBody1"}' \
http://localhost:8000/api/posts/1 | jq
{
"post": {
"id": 1,
"title": "NewTitle",
"body": "NewBody1",
・・・
}
}
他人の記事を更新しようとするとエラーになるかを動作確認
$ curl -X PATCH -H "Content-Type: application/json" \
-H "Authorization: Bearer xxxxxxxxxx" \
-d '{"title":"NewTitle","body":"NewBody1"}' \
http://localhost:8000/api/posts/2 -v
・・・
HTTP/1.1 403 Forbidden
・・・
testを書く
handlerのテストは自分の記事は更新できて他人の記事は更新できないことをテストする
自分の記事を更新
func TestUpdatePostSuccess(t *testing.T) {
setup()
reqJSON := `{"title":"NewTitle", "body":"NewBody"}`
authMiddleware := middleware.AuthMiddleware(h.authStore, h.userStore)
req := httptest.NewRequest(echo.PATCH, "/api/posts/:id", strings.NewReader(reqJSON))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer: ValidToken1")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPath("/api/posts/:id")
c.SetParamNames("id")
c.SetParamValues("1")
err := authMiddleware(func(c echo.Context) error {
return h.updatePost(c)
})(c)
assert.NoError(t, err)
if assert.Equal(t, http.StatusOK, rec.Code) {
var res postResponse
err := json.Unmarshal(rec.Body.Bytes(), &res)
assert.NoError(t, err)
assert.Equal(t, "NewTitle", res.Post.Title)
assert.Equal(t, "NewBody", res.Post.Body)
}
}
testでは、認証ミドルウェアで行なっているidTokenからFirebaseユーザーidの変換にidTokenによって固定のユーザーidを返すベースプロジェクトで用意したモックメソッドをを使う。
func (f *fakeAuthClient) VerifyIDToken(context context.Context, token string) (*auth.Token, error) {
var uid string
if token == "ValidToken" {
uid = "ValidUID"
return &auth.Token{UID: uid}, nil
} else if token == "ValidToken1" {
uid = "ValidUID1"
return &auth.Token{UID: uid}, nil
} else {
return nil, errors.New("Invalid Token")
}
}
他人の記事を更新しようとする
func TestUpdatePostForbidden(t *testing.T) {
setup()
reqJSON := `{"title":"NewTitle", "body":"NewBody"}`
authMiddleware := middleware.AuthMiddleware(h.authStore, h.userStore)
req := httptest.NewRequest(echo.PATCH, "/api/posts/:id", strings.NewReader(reqJSON))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, "Bearer: ValidToken1")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPath("/api/posts/:id")
c.SetParamNames("id")
c.SetParamValues("2")
err := authMiddleware(func(c echo.Context) error {
return h.updatePost(c)
})(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusForbidden, rec.Code)
}
テスト実行
$ go test -run TestUpdatePostSuccess
・・・
ok go-echo-starter/handler 1.380s
$ go test -run TestUpdatePostForbidden
・・・
ok go-echo-starter/handler 1.380s
さいごに
今回作ったサンプル
Discussion