🍡

【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から記法を拝借)。

https://tech-book.precena.co.jp/software/backend/openapi/separate_file

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.yaml
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 ディレクトリ以下のファイルで構成されるエンドポイントの一覧を定義しています。

/paths/users/index.yaml
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 を参照しています。

/components/responses/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"
/components/schemas/user.yaml
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

https://redocly.com/docs/cli/commands/bundle#bundle-a-single-api-description

コード生成

oapi-codegenの使い方

oapi-codegenの設定ファイルをつくります。オプションの付与により生成されるコードは、主に3つに分割できます。

  • スキーマに基づいたGoの構造体 (json タグ付き)
  • ルーティング (Echo)
    • (ハンドラをメソッドに持つ) ルートごとのインターフェース
    • リクエストのバリデーション
    • ハンドラを登録するための関数
  • Swaggerオブジェクトを取得するための関数
/openapi/config.yaml
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

生成されたコードの使い方

コマンドで生成されたコードの一部を抜粋します。基本的には、これらのコードを組み合わせてルーティングを実装していきます。

openapi.gen.go
// サーバーのインターフェース (すべてのルートのメソッドが定義される)
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とは違いますね。

main.go
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スキーマがフロントエンドとバックエンドのハブとして機能するため、メンテナンスのしやすさが重要だと思っています (実装との乖離が起きると開発体験が悪い)。この記事が何かの参考になれば幸いです。

https://github.com/yuki-yamamura/echo-with-openapi

株式会社FLAT テックブログ

Discussion