[Go言語]oapi-codegenで生成したechoサーバに個別ミドルウェアが設定できないから、ルーティングを手書き&単体テストで補う

# 概要

OpenAPI(Swagger)でAPIのスキーマ定義をyamlで書くスキーマ駆動開発を試みました。

[oapi-codegen](https://github.com/oapi-codegen/oapi-codegen)を使えばyamlファイルに記述したOpenAPIのスキーマ定義を基に、Go言語の構造体や[echo](https://echo.labstack.com)向けにルーティングも自動生成できるので便利です。しかしながら、自動生成されたコードのechoルーティングは個別のエンドポイントにミドルウェアを設定することが難しい状態です。

そこで、oapi-codegenが自動生成したコードを活用しつつ、手動でルーティングを実装し、手動実装した部分に漏れや間違いがないようにOpenAPIのスキーマ定義と比較する単体テストを記述することでスキーマ駆動開発を試みました。

# この記事で書くこと・やること

1. oapi-codegenを使ってOpenAPIスキーマ定義からGo言語の構造体、echo用サーバのコードを生成する
1. ミドルウェア設定のために、手動でルーティングするコードを実装する
1. 手動で書いたルーティングに漏れや間違いがないかスキーマ定義と比較する単体テストを書く

# 手順
## 1. oapi-codgenの準備
まだoapi-codegenが準備出来ていなければ下記コマンドでインストールします。

```shell
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
```

## 2. OpenAPIのyamlを記述します。
YAML記述の詳細は省略しますが、下記のようなyamlを書くものとします。

```yaml:openapi-bundle.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を実行します。

```yaml:oapi-codegen.yaml
package: schema
generate:
  - models
  - echo-server
# ↓ 出力先を指定します。
output: interfaces/http/schema/schema.gen.go
```

```shell
# コード生成：
# -configに設定ファイルを指定、そしてOpenAPIのyamlファイルを指定
oapi-codegen -config ./oapi-codegen.yaml ./openapi-bundle.yaml
```

Go言語のコードが自動生成されます。設定ファイルに`echo-server`も指定したのでecho向けのコードも下記のように記述されています。`ServerInterface`では実装すべきハンドラ関数が定義されています。またルーティング処理も記述されていますが、個別にミドルウェアを設定することが難しい状態になっています。

```go:interfaces/http/schema/schema.gen.go
// ~~ 省略

// 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`のようにミドルウェアを設定しています。
```go:interfaces/http/route/router.go
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ファイルを読み込んで定義されているエンドポイントと一致しているか担保する単体テストです。メソッドとパスに誤りや漏れがないかをチェックします。

```go:interfaces/http/router/router_test.go
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がミドルウェアをエンドポイントごとに設定できないルーティングコードを生成するので、ミドルウェア設定に試行錯誤されているようでした。

- [自動生成されたhttpエンドポイントごとにカスタムミドルウェアを挿入したい話 https://gocon.jp/2024/sessions/21/](https://gocon.jp/2024/sessions/21/)

- [oapi-codegenを使ってみた https://speakerdeck.com/akeno/oapi-codegenwoshi-tutemita](https://speakerdeck.com/akeno/oapi-codegenwoshi-tutemita)