【Go × OpenAPI】スキーマ駆動でつくるREST API
バックエンドのAPI開発において、スキーマ駆動のアプローチを採用することでコードの自動生成による恩恵を受けられます。この記事では、OpenAPIの定義からGoのコードを生成する流れを解説します。Goを使ったAPIの開発を考えており、スキーマ駆動に興味がある方はぜひ読んでください。
技術スタック
本記事で使用する技術スタックは以下の通りです:
- Echo: APIのルーティングに使用する
- Redocly CLI: 複数ファイルに分割したOpenAPIのバンドルに使用する
- oapi-codegen: OpenAPIからGoコードの生成に使用する
OpenAPIでスキーマを定義
まずは、OpenAPIスキーマでAPIの仕様を定義していきます。書き始めるにあたり、エンドポイントが増えてくると単一のファイルでは収集がつかなくことが予想できるので、スケールしやすいディレクトリ構成とファイル分割方法を検討しました。
OpenAPIの構成要素
OpenAPIスキーマはいくつかのセクションに分解できます。以下の例では、Pathsセクション内の /users
エンドポイントでさらに3つのセクションが定義されています。
-
parameters
: パスパラメータ、クエリパラメータなどの情報を記載する -
requestBody
: リクエストボディの情報を記載する -
responses
: レスポンスの情報を記載する
これらは再利用可能な部品を配置するComponentsセクションで定義されており、$ref
で参照することができます。
openapi.gen.yaml
openapi: 3.0.0
info:
title: Test API
version: 1.0.0
# エンドポイントを定義するPathsセクション
paths:
/users:
get:
summary: Returns a list of users.
# レスポンスの仕様
responses:
'200':
$ref: '#/components/responses/GetUsersResponseData'
'500':
$ref: '#/components/responses/ErrorResponseData'
put:
summary: Updates a user.
# URLパラメータの仕様
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
# リクエストボディの仕様
requestBody:
$ref: '#/components/requestBodies/UpdateUserRequestBody'
responses:
'200':
$ref: '#/components/responses/UpdateUserResponseData'
'404':
$ref: '#/components/responses/ErrorResponseData'
'500':
$ref: '#/components/responses/ErrorResponseData'
# 再利用可能な部品を定義するComponentsセクション
components:
schemas:
User:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
maxLength: 10
email:
type: string
format: email
required:
- id
- name
- email
Error:
type: object
properties:
message:
type: string
required:
- message
responses:
GetUsersResponseData:
description: Response containing a list of users
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
example:
- id: '987654321'
name: Alice
email: alice@example.com
- id: '123456789'
name: Bob
email: bob@example.com
ErrorResponseData:
description: Response containing an error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
message: Bad Request
UpdateUserResponseData:
description: Response containing the updated user data
content:
application/json:
schema:
$ref: '#/components/schemas/User'
example:
id: '123456789'
name: Bob
email: bob@example.com
requestBodies:
UpdateUserRequestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
maxLength: 10
email:
type: string
format: email
required:
- name
- email
ファイルの分割
上記のセクションを分割の粒度として、以下のようなディレクトリ構造にしました。意識したことは、一貫性があり、破綻しにくい構成と命名です。paths
ディレクトリにはルートと同じ粒度でファイルを配置しました (Next.jsのFile-Based Routingから記法を拝借)。
openapi
├── components
│ ├── parameters # URLパラメータの定義
│ │ └── user.yaml
│ ├── requestBodies # リクエストボディの定義
│ │ └── user.yaml
│ ├── responses # レスポンスの定義
│ │ ├── error.yaml
│ │ └── user.yaml
│ └── schemas # データモデルの定義
│ ├── error.yaml
│ └── user.yaml
├── openapi.gen.yaml # 生成された結合ファイル
├── openapi.yaml # エントリーポイント
└── paths # APIパスの定義
└── users
├── [id].yaml # /users/{id}エンドポイント
└── index.yaml # /usersエンドポイント
openapi.yaml
がエントリーポイントのファイルになっており、paths
セクションを定義しています。
openapi: 3.0.0
info:
title: Test API
version: 1.0.0
paths:
/users:
$ref: "./paths/users/index.yaml"
paths
から参照される paths/users/index.yaml
は、components
ディレクトリ以下のファイルで構成されるエンドポイントの一覧を定義しています。
get:
summary: Returns a list of users.
responses:
"200":
$ref: "../../components/responses/user.yaml#/GetUsersResponseData"
"500":
$ref: "../../components/responses/error.yaml#/ErrorResponseData"
components
ディレクトリでは、リクエストやレスポンスがモデルを参照します。例えば、レスポンス components/responses/user.yaml
はモデル components/schemas/user.yaml
を参照しています。
GetUsersResponseData:
description: Response containing a list of users
content:
application/json:
schema:
type: array
items:
$ref: "../schemas/user.yaml#/User"
example:
- id: "987654321"
name: "Alice"
email: "alice@example.com"
- id: "123456789"
name: "Bob"
email: "bob@example.com"
User:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
maxLength: 10
email:
type: string
format: email
required:
- id
- name
- email
ファイルの結合
コード生成の前にOpenAPIのファイルを1つにまとめる必要があります。oapi-codegen
はファイルを跨ぐ $ref
の参照に対応していないため、Redocly CLIで分割したファイルを結合して openapi.gen.yaml
を生成します。
docker run --rm -v $PWD:/spec redocly/cli bundle ./openapi/openapi.yaml -o openapi/openapi.gen.yaml
コード生成
oapi-codegenの使い方
oapi-codegenの設定ファイルをつくります。オプションの付与により生成されるコードは、主に3つに分割できます。
- スキーマに基づいたGoの構造体 (
json
タグ付き) - ルーティング (Echo)
- (ハンドラをメソッドに持つ) ルートごとのインターフェース
- リクエストのバリデーション
- ハンドラを登録するための関数
- Swaggerオブジェクトを取得するための関数
package: schema
generate:
echo-server: true
strict-server: true
models: true
embedded-spec: true
output: ./internal/presentation/schema/openapi.gen.go
以下のコマンドを実行すると、OpenAPIファイルをバンドルした openapi.gen.yaml
が生成されます。
oapi-codegen --config ./openapi/config.yaml ./openapi/openapi.gen.yaml
生成されたコードの使い方
コマンドで生成されたコードの一部を抜粋します。基本的には、これらのコードを組み合わせてルーティングを実装していきます。
// サーバーのインターフェース (すべてのルートのメソッドが定義される)
type StrictServerInterface interface {
GetUsers(ctx context.Context, request GetUsersRequestObject) (GetUsersResponseObject, error)
}
// モデルの構造体
type User struct {
Email openapi_types.Email `json:"email"`
Id int64 `json:"id"`
Name string `json:"name"`
}
// リスポンスデータの構造体
type GetUsersResponseDataJSONResponse []User
// ハンドラを登録するための関数
func RegisterHandlers(router EchoRouter, si ServerInterface) {
RegisterHandlersWithBaseURL(router, si, "")
}
func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) {
wrapper := ServerInterfaceWrapper{
Handler: si,
}
router.GET(baseURL+"/users", wrapper.GetUsers)
}
利用側のコードでは、生成されたインターフェースを満たすハンドラを実装していきます。ここで、戻り値は error
型となっており型安全ではありません。この辺がTypeScriptとは違いますね。
type Server struct{}
// インターフェースに沿ってメソッドを実装
func (s *Server) GetUsers(ctx echo.Context) error {
return GetUsers(ctx)
}
func GetUsers(c echo.Context) error {
return c.JSON(http.StatusOK, GetUsersResponseDataJSONResponse{
{
Id: 1,
Name: "Alice",
Email: "alice@example.com",
},
{
Id: 2,
Name: "Bob",
Email: "bob@example.com",
},
})
}
func NewRouter() *echo.Echo {
e := echo.New()
// 実装したサーバーのハンドラを登録
schema.RegisterHandlers(e, &Server{})
return e
}
func main() {
// ルーティングの設定
r := router.NewRouter()
// サーバーを起動
r.Logger.Fatal(r.Start(":1323"))
}
リクエストのバリデーション
生成されるコードでは、パラメータとボディの型まで保証できるのですが、OpenAPIで定義した maxLength
などの細かい入力チェックはしてくれません。条件を満たさないリクエストを受け取った場合、後続の処理に入る前に 400 (Bad Request) を返したいので、追加のバリデーションを行います。
oapi-codegenを使用した際、embedded-spec
オプションを有効にしました。そのため、Swaggerオブジェクトを取得する関数 GetSwagger
が生成されているはずです。これを oapi-codegen/echo-middleware
パッケージで公開されているミドルウェアに読み込ませることでリクエストのバリデーションを実装できます。
+ mw "github.com/oapi-codegen/echo-middleware"
func NewRouter() *echo.Echo {
e := echo.New()
+ swagger, err := schema.GetSwagger()
+ if err != nil {
+ e.Logger.Fatal(err)
+ }
+ // API用のグループを作成
+ rootGroup := e.Group("")
+ // バリデーションのミドルウェアを設定
+ rootGroup.User(mw.OapiRequestValidator(swagger))
schema.RegisterHandlers(rootGroup, &Server{})
return e
}
OpenAPIでユーザーの名前は10文字以下と定義しているので、それに違反するリクエストボディを /users
エンドポイントにPOSTしてみます。「リクエストボディの name
プロパティが長すぎる」とエラーを返してくれました。
curl -X POST http://localhost:1323/users \
-H "Content-Type: application/json" \
-d '{
"name": "too-long-name",
"email": "test@example.com"
}'
{"message":"request body has an error: doesn't match schema: Error at \"/name\": maximum string length is 10"}
外部ツールで読み込むJSONを用意
フロントエンドをTypeScriptで実装する場合、スキーマ駆動開発ではフロントの型定義やフェッチャーを作成することが多いと思います。例えば、openapi-fetchでは、OpenAPI仕様を定義したJSONファイルを読み込んで以下のようなコードを生成できます。
import createClient from "openapi-fetch";
import { paths } from "./api.get";
const client = createClient<paths>({
baseUrl: "http://localhost:1323",
});
// `data` の型は `{ email: string, id: number, name: string}[] | undefined
const { data, error, response } = await client.GET("/users");
また、Swaggerで読み込むことでAPIドキュメントも生成できます。
今回は、フロントエンドとバックエンドが別々のリポジトリで管理されていることを想定しているため、/docs/swagger.json
にHTTPでアクセスできるようにします。まずは、Echoのハンドラを返す高階関数を作成します。
func WithSwaggerJson(s *openapi3.T) func(c echo.Context) error {
return func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
c.Response().WriteHeader(http.StatusOK)
encoder := json.NewEncoder(c.Response())
// 引数で受け取ったSwaggerオブジェクトをJSONにシリアライズして返す
return encoder.Encode(s)
}
}
あとは、/docs/
グループを作成してルートを追加するだけです。サーバーを立ち上げると、JSONにアクセスできます。
e := echo.New()
swagger, err := schema.GetSwagger()
if err != nil {
e.Logger.Fatal(err)
}
+ docsGroup := e.Group("/docs")
+ docsGroup.GET("/swagger.json", WithSwaggerJson(swagger))
rootGroup := e.Group("")
rootGroup.Use(mw.OapiRequestValidator(swagger))
おわりに
スキーマ駆動開発を採用することで、事前の設計に沿ったAPI実装が効率的に進められます。一方で、OpenAPIスキーマがフロントエンドとバックエンドのハブとして機能するため、メンテナンスのしやすさが重要だと思っています (実装との乖離が起きると開発体験が悪い)。この記事が何かの参考になれば幸いです。
Discussion