💬

Go Echo APIサーバーの開発

2022/05/11に公開約10,900字

以下の機能の最小構成のスタートプロジェクトを基に簡単なapiサーバーを作る

sql-migrateによるdbマイグレーション
gormによるアプリからのdb操作
go-playground/validatorによる入力チェック
・本番、開発環境ごとに設定ファイルの切替え
・ユーザー認証のミドルウェア

https://github.com/nrikiji/go-echo-sample

また、ユーザー認証にはFirebase Authenticationを、データベースはMySQLを使うことを想定

作るもの

ブログの記事一覧取得するAPIと投稿した記事を更新するAPIの2つ。記事の一覧APIには誰でもアクセス可能で、記事の更新APIは投稿した本人のみがアクセス可能なものとする

前準備

セットアップ

ベースプロジェクトをクローン

$ git clone https://github.com/nrikiji/go-echo-sample

データベースの接続情報を環境に合わせて編集

config.yml
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を準備

model/post.go
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メソッドを追加

store/post.go
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で返す部分を実装する

実装

handler/post.go
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を呼び出すようにする

handler/routes.go
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件用意する

fixtures/posts.yml
- id: 1
  user_id: 1
  title: "Title1"
  body: "Body1"

- id: 2
  user_id: 2
  title: "Title2"
  body: "Body2"

handlerのテストを書く。ここではエラーにならないことと件数が一致していることをテストする

handler/post_test.go
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 {
	// 認証成功
}

実装

handler/post.go
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を呼び出すようにする

handler/routes.go
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のテストは自分の記事は更新できて他人の記事は更新できないことをテストする

自分の記事を更新

handler/post_test.go
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")
	}
}

他人の記事を更新しようとする

handler/post_test.go
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

さいごに

今回作ったサンプル

https://github.com/nrikiji/go-echo-sample/tree/blog-example

Discussion

ログインするとコメントできます