概要
OpenAPI(Swagger)でAPIのスキーマ定義をyamlで書くスキーマ駆動開発を試みました。
oapi-codegenを使えばyamlファイルに記述したOpenAPIのスキーマ定義を基に、Go言語の構造体やecho向けにルーティングも自動生成できるので便利です。しかしながら、自動生成されたコードのechoルーティングは個別のエンドポイントにミドルウェアを設定することが難しい状態です。
そこで、oapi-codegenが自動生成したコードを活用しつつ、手動でルーティングを実装し、手動実装した部分に漏れや間違いがないようにOpenAPIのスキーマ定義と比較する単体テストを記述することでスキーマ駆動開発を試みました。
この記事で書くこと・やること
- oapi-codegenを使ってOpenAPIスキーマ定義からGo言語の構造体、echo用サーバのコードを生成する
- ミドルウェア設定のために、手動でルーティングするコードを実装する
- 手動で書いたルーティングに漏れや間違いがないかスキーマ定義と比較する単体テストを書く
手順
1. oapi-codgenの準備
まだoapi-codegenが準備出来ていなければ下記コマンドでインストールします。
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
2. OpenAPIのyamlを記述します。
YAML記述の詳細は省略しますが、下記のようなyamlを書くものとします。
openapi: 3.0.0
info:
title: API
description: サンプルAPI
version: 1.0.0
servers:
- url: http://localhost:8080
description: ローカル開発環境
paths:
/auth/sign_up:
post:
operationId: SignUp
summary: アカウント作成
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
myId:
type: string
description: 取得したいMyIDを指定します。
x-oapi-codegen-extra-tags:
validate: required
password:
type: string
x-oapi-codegen-extra-tags:
validate: required
isAdmin:
type: boolean
required:
- myId
- password
- isAdmin
responses:
'200':
description: アカウント作成に成功
content:
application/json:
schema:
type: object
properties:
myId:
type: string
required:
- myId
'400':
description: リクエストに不備がある
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/sign_in:
post:
operationId: SignIn
summary: サインインします。
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SignInResponse'
responses:
'200':
description: サインイン成功
content: {}
'401':
description: サインイン失敗
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/sign_out:
post:
operationId: signOut
summary: サインアウトします。
security:
- cookieAuth: []
responses:
'200':
description: サインアウト
content: {}
'401':
description: 未サインイン
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/photos/{photoId}:
get:
operationId: getPhoto
summary: 写真情報を取得します。
security:
- cookieAuth: []
parameters:
- name: photoId
in: path
required: true
schema:
type: integer
responses:
'200':
description: 写真情報
content:
application/json:
schema:
$ref: '#/components/schemas/GetPhotoResponse'
'404':
description: 見つからなかった場合
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/photos:
get:
operationId: getPhotos
summary: 写真一覧を取得します。
security:
- cookieAuth: []
parameters:
- name: limit
in: query
schema:
type: integer
- name: offset
in: query
schema:
type: integer
responses:
'200':
description: 写真情報
content:
application/json:
schema:
$ref: '#/components/schemas/GetPhotoListResponse'
'404':
description: 見つからなかった場合
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: session
schemas:
ErrorResponse:
type: object
description: エラーレスポンス
properties:
statusCode:
type: integer
description: HTTPステータスコード
errorCode:
type: string
description: APIが定義するエラーコード
errorMessage:
type: string
description: システムエラーメッセージ詳細
required:
- statusCode
- errorCode
SignInResponse:
type: object
properties:
myId:
type: string
x-oapi-codegen-extra-tags:
validate: required
password:
type: string
x-oapi-codegen-extra-tags:
validate: required
required:
- myId
- password
GetPhotoListResponse:
properties:
items:
type: array
items:
$ref: '#/components/schemas/GetPhotoListItem'
total:
type: integer
required:
- items
- total
GetPhotoListItem:
properties:
photoId:
type: integer
required:
- photoId
GetPhotoResponse:
properties:
photoId:
type: integer
photoName:
type: string
required:
- photoId
- photoName
3. oapi-codegenでGo言語コードの生成
下記のような設定ファイルを作成してoapi-codegenを実行します。
package: schema
generate:
- models
- echo-server
# ↓ 出力先を指定します。
output: interfaces/http/schema/schema.gen.go
# コード生成:
# -configに設定ファイルを指定、そしてOpenAPIのyamlファイルを指定
oapi-codegen -config ./oapi-codegen.yaml ./openapi-bundle.yaml
Go言語のコードが自動生成されます。設定ファイルにecho-server
も指定したのでecho向けのコードも下記のように記述されています。ServerInterface
では実装すべきハンドラ関数が定義されています。またルーティング処理も記述されていますが、個別にミドルウェアを設定することが難しい状態になっています。
// ~~ 省略
// ServerInterface represents all server handlers.
type ServerInterface interface {
// サインインします。
// (POST /auth/sign_in)
SignIn(ctx echo.Context) error
// サインアウトします。
// (POST /auth/sign_out)
SignOut(ctx echo.Context) error
// アカウント作成
// (POST /auth/sign_up)
SignUp(ctx echo.Context) error
// 写真一覧を取得します。
// (GET /photos)
GetPhotos(ctx echo.Context, params GetPhotosParams) error
// 写真情報を取得します。
// (GET /photos/{photoId})
GetPhoto(ctx echo.Context, photoId int) error
}
// ~~ 省略
// RegisterHandlers adds each server route to the EchoRouter.
func RegisterHandlers(router EchoRouter, si ServerInterface) {
RegisterHandlersWithBaseURL(router, si, "")
}
// Registers handlers, and prepends BaseURL to the paths, so that the paths
// can be served under a prefix.
func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) {
wrapper := ServerInterfaceWrapper{
Handler: si,
}
router.POST(baseURL+"/auth/sign_in", wrapper.SignIn)
router.POST(baseURL+"/auth/sign_out", wrapper.SignOut)
router.POST(baseURL+"/auth/sign_up", wrapper.SignUp)
router.GET(baseURL+"/photos", wrapper.GetPhotos)
router.GET(baseURL+"/photos/:photoId", wrapper.GetPhoto)
}
4. ルーティング処理を手動実装
本来ならば、自動生成された RegisterHandlers
やRegisterHandlersWithBaseURL
をそのまま利用できるのが理想ですが、エンドポイントごとに個別にミドルウェアを設定したい場合は利用できません。そこで諦めてルーティング処理だけは自前で手動実装します。
下記は(現時点ではほとんど同じですが)自動生成のルーティング処理を元に手動で記述したルーティング処理です。一部のエンドポイントは middlewares.AuthUser
のようにミドルウェアを設定しています。
func Route(e schema.EchoRouter, si schema.ServerInterface) {
w := schema.ServerInterfaceWrapper{
Handler: si,
}
e.POST("/auth/sign_up", w.SignUp)
e.POST("/auth/sign_in", w.SignIn)
e.POST("/auth/sign_out", w.SignOut, middelwares.AuthUser)
e.GET("/photos", w.GetPhotos, middelwares.AuthUser)
e.GET("/photos/:photoId", w.GetPhoto, middelwares.AuthUser)
}
一応、これで完成ではあります。自動生成されたルーティング処理が使えなかったので代わりに手動で実装しました。
5. 漏れや間違いが無いか単体テストを書く
上記までで処理自体の実装は完了しているのですが、せっかくスキーマ駆動開発を試みたのにルーティング処理を手動で記述して漏れや間違いがおこりえます。
そこで、次のような単体テストを書くことで、yamlで定義したOpenAPIのスキーマ定義と手動記述したルーティング処理が一致しているかを担保します。
これは、手動記述したルーティング関数でセットされたエンドポイントが、OpenAPIのyamlファイルを読み込んで定義されているエンドポイントと一致しているか担保する単体テストです。メソッドとパスに誤りや漏れがないかをチェックします。
package routers
import (
"context"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/getkin/kin-openapi/openapi3"
"github.com/labstack/echo/v4"
)
func TestApiRouter_route(t *testing.T) {
loadOpenAPISpec := func(t *testing.T, path string) *openapi3.T {
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(path)
assert.NoError(t, err, "failed to load OpenAPI")
err = doc.Validate(context.Background())
assert.NoError(t, err, "failed to validate OpenAPI")
return doc
}
getRegisteredRoutes := func(e *echo.Echo) map[string]string {
routes := make(map[string]string) // key: METHOD PATH, value: name
for _, r := range e.Routes() {
key := r.Method + " " + r.Path
routes[key] = r.Name
}
return routes
}
// OpenAPIのパスパラメータ`{photoId}`形式をechoのパスパラメータ`:photoId`に変換する。
openAPIPathToEchoPath := func(path string) string {
re := regexp.MustCompile(`\{([^}]+)\}`)
return re.ReplaceAllString(path, `:$1`)
}
t.Run("ルーティングのパスがOpenAPI定義と一致しているかテスト", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
e := echo.New()
h := &ServerHandler{} // ServerInterfaceの実装をセット(モックでもOK)
Route(e, h)
actual := getRegisteredRoutes(e)
doc := loadOpenAPISpec(t, "/path/to/openapi-bundle.yaml")
for path, pathItem := range doc.Paths.Map() {
for method, operation := range pathItem.Operations() {
method = strings.ToUpper(method)
echoPath := openAPIPathToEchoPath(path)
key := method + " " + echoPath
if _, ok := actual[key]; !ok {
t.Errorf("Missing route implementation: %s %s (operationId: %s)", method, path, operation.OperationID)
}
}
}
})
}
まとめ
oapi-codegenでOpenAPIのスキーマ定義からGo言語のコードを自動生成しました。しかしながら、自動生成されたルーティング処理はミドルウェアを個別に指定できなかったため、ルーティング処理を手動で実装しました。そして、手動での記述に漏れや間違いがないかをチェックする単体テストを書きました。
付録
他のやり方
他の方も、oapi-codegenがミドルウェアをエンドポイントごとに設定できないルーティングコードを生成するので、ミドルウェア設定に試行錯誤されているようでした。