8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ミクシィグループAdvent Calendar 2021

Day 24

deepmap/oapi-codegen のカスタムテンプレートで required array が null で返ってしまうのを防ぐ

Last updated at Posted at 2021-12-23

はじめに

私が今関わっているプロジェクトでは、サーバーサイドにGo言語を採用し、Webフレームワークとして labstack/echo (以下、Echo) を採用しています。また、APIの定義を OpenAPI で行い、OpenAPI の定義から Echo のハンドラを一括登録するための関数やリクエスト/レスポンスの型を生成するために deepmap/oapi-codegen(以下、oapi-codegen)を利用しています。

OpenAPI を利用する目的の一つとして、クライアントとサーバー双方が一意な解釈が可能な API 定義を参照することによって認識の齟齬なく開発を進められるようすることが挙げられます。しかし、 oapi-codegen が生成するコードを使用した際に、 required な array と定義されているプロパティについて null として返却してしまう問題があります。これによって、 OpenAPI による定義を満たしていない API を実装してしまうことがあり、クライアントが API レスポンスを処理できず手戻りやバグの原因となっていました。

この記事では、この問題の調査・解決の流れを説明します。

問題の整理

実際に OpenAPI.yaml を定義してコード生成をし、サーバーを実装することで問題の確認をします。言語やツールは以下のバージョンを使用しています。

  • Go v1.17.2
  • Echo v4.2.1
  • oapi-codegen v1.9.0

下記のような OpenAPI.yaml を用意します。 GetPetsResponse 中の pets が required な array となっていることを確認してください。

OpenAPI.yaml
openapi: "3.0.0"
paths:
  /pets:
    get:
      summary: Returns all pets
      operationId: getPets
      responses:
        '200':
          description: pet response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetPetsResponse'
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    GetPetsResponse:
      required:
        - pets
      properties:
        pets:
          type: array
          items:
            $ref: '#/components/schemas/Pet'

    Pet:
      required:
        - id
      properties:
        id:
          type: integer
          format: int64
          description: Unique id of the pet

    Error:
      required:
        - code
        - message
      properties:
        code:
          type: integer
          format: int32
          description: Error code
        message:
          type: string
          description: Error message

以下のように oapi-codegen を実行すると、サーバーを実装するのに必要な型や関数が定義されたコードが生成されます(簡単のため、 main package を指定しています)。

$ oapi-codegen -package main -generate types,server OpenAPI.yaml > server.gen.go
server.gen.go
// Package main provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/deepmap/oapi-codegen version v1.9.0 DO NOT EDIT.
package main

import (
	"github.com/labstack/echo/v4"
)

// Error defines model for Error.
type Error struct {
	// Error code
	Code int32 `json:"code"`

	// Error message
	Message string `json:"message"`
}

// GetPetsResponse defines model for GetPetsResponse.
type GetPetsResponse struct {
	Pets []Pet `json:"pets"`
}

// Pet defines model for Pet.
type Pet struct {
	// Unique id of the pet
	Id int64 `json:"id"`
}

// ServerInterface represents all server handlers.
type ServerInterface interface {
	// Returns all pets
	// (GET /pets)
	GetPets(ctx echo.Context) error
}

// ServerInterfaceWrapper converts echo contexts to parameters.
type ServerInterfaceWrapper struct {
	Handler ServerInterface
}

// GetPets converts echo context to params.
func (w *ServerInterfaceWrapper) GetPets(ctx echo.Context) error {
	var err error

	// Invoke the callback with all the unmarshalled arguments
	err = w.Handler.GetPets(ctx)
	return err
}

// This is a simple interface which specifies echo.Route addition functions which
// are present on both echo.Echo and echo.Group, since we want to allow using
// either of them for path registration
type EchoRouter interface {
	CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
}

// 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.GET(baseURL+"/pets", wrapper.GetPets)

}

この生成コードを利用して、 Echo を用いた HTTP サーバーを実装します。 GetPetsResponse の初期化時に、 pets を明示的に指定しないことによってゼロ値となり、 nil が格納されることに注意してください。

main.go
package main

import (
	"log"
	"net/http"

	"github.com/labstack/echo/v4"
)

type Handler struct {
	GetPetsHandler
}

type GetPetsHandler struct{}

func (h *GetPetsHandler) GetPets(ctx echo.Context) error {
	return ctx.JSON(http.StatusOK, &GetPetsResponse{})
}

func main() {
	e := echo.New()
	RegisterHandlers(e, &Handler{GetPetsHandler: GetPetsHandler{}})
	if err := e.Start(":8080"); err != nil {
		if err != http.ErrServerClosed {
			log.Fatal(err)
		}
	}
}

このサーバーを実行し、実際にリクエストを送ってみます。

$ curl localhost:8080/pets
{"pets":null}

pets が required 指定されているにも関わらず null として返却されているのがわかります。これは OpenAPI.yaml の定義に反しており、問題があります。特に、ある API のレスポンスにプロパティを追加し、サーバー側でコードの再生成のみを行ってクライアントとサーバーの開発を並行して行おうとした場合に、他のプロパティではゼロ値による初期化がうまく働いて定義に従った値が返却されるのに対し、 required array が追加されていた場合は空のスライスで初期化するなどの対応が必要になります。

既存 issue の調査

この問題がすでに oapi-codegen の issue として存在しないか調査したところ、それらしき issue がありました。

内容を読んでみると、Go 本体の issue にリンクされていることがわかったので、こちらも読んでみます。

encoding/json パッケージで nil slice を JSON にシリアライズするときに、 [] とするように struct tag で指定する機能の提案のようです。しかし、議論は膠着しており、すぐに利用できるようになったりはしなさそうです。oapi-codegen のメンテナもハンドラの実装で必ず空のスライスで初期化することで対応しており、できればデフォルトの JSON marshaler をオーバーライドしたくないと考えているようなので、あまり期待はできません。そこで、何かワークアラウンドとなる解決策がないか考えてみます。

解決策

今のプロジェクトでは requied array を空のスライスで初期化するように気をつけていても漏れが発生しているため、デフォルトの JSON marshaler をオーバーライドしてでも解決したいところです。 encoding/json パッケージの json.Marshal 関数は、型に MarshalJSON メソッドを実装することでその挙動をオーバーライドできます。

ただし、 required array を持つスキーマから自動生成された型に手動で1つ1つ MarshalJSON メソッドを実装していくのは空のスライスで初期化していくのと実質的に変わらず、むしろ空のスライスでの初期化よりも複雑になるので何か自動的に定義する方法を用意したいです。私は oapi-codegen に何度か PR を出したり、oapi-codegen がコード生成に利用する構造体を使ってプロジェクトに特化したコードジェネレータを作ったりしたことがあったので、今回も oapi-codegen の構造体を流用して required array を持つスキーマに対して MarshalJSON を定義するコードジェネレータを作成することでこの問題を解決しようと考えました。

しかし、改めて oapi-codegen のコードを読んでいるうちに、自前でコードジェネレータを記述しなくてもユーザーが定義したカスタムテンプレートを渡す -templates オプションがあることがわかりました1

これを利用して型定義用のテンプレートを置き換え、 MarshalJSON を定義できるようにしてみます。テンプレートのファイル名は生成対象 (oapi-codegen の -generate オプション) と対応していて固定なので注意してください。今回は型定義を生成するテンプレートをオーバーライドするので typedef.tmpl というファイル名にする必要があります(他に利用可能なファイル名は https://github.com/deepmap/oapi-codegen/tree/32316b9c067a927466db3d16958f290cba4841b8/pkg/codegen/templates などを参照してください)。

templates/typedef.tmpl
{{range .Types}}
{{ with .Schema.Description }}{{ . }}{{ else }}// {{.TypeName}} defines model for {{.JsonName}}.{{ end }}
type {{.TypeName}} {{if and (opts.AliasTypes) (.CanAlias)}}={{end}} {{.Schema.TypeDecl}}

{{ $haveRequiredSliceField := false }}
{{range .Schema.Properties}}
    {{if and (.Required) (not .Nullable) (.Schema.ArrayType)}}
        {{ $haveRequiredSliceField = true }}
    {{end}}
{{end}}

{{ if $haveRequiredSliceField }}
// Override default JSON handling for {{.TypeName}} to fill required nil slice
func (a {{.TypeName}}) MarshalJSON() ([]byte, error) {
    type Alias {{.TypeName}}
    alias := Alias(a)
{{range .Schema.Properties}}
    {{if and (.Required) (not .Nullable) (.Schema.ArrayType)}}
    if alias.{{.GoFieldName}} == nil {
        alias.{{.GoFieldName}} = make([]{{.Schema.ArrayType.TypeDecl}}, 0)
    }
    {{end}}
{{end}}
    return json.Marshal(alias)
}
{{end}}
{{end}}

oapi-codegen -package main -generate types,server -templates templates OpenAPI.yaml > server.gen.go のように -templates オプションを指定してコードを再生成すると、以下のような差分が発生します。

 package main

 import (
+	"encoding/json"
+
 	"github.com/labstack/echo/v4"
 )

@@ -21,6 +23,18 @@
 	Pets []Pet `json:"pets"`
 }

+// Override default JSON handling for GetPetsResponse to fill required nil slice
+func (a GetPetsResponse) MarshalJSON() ([]byte, error) {
+	type Alias GetPetsResponse
+	alias := Alias(a)
+
+	if alias.Pets == nil {
+		alias.Pets = make([]Pet, 0)
+	}
+
+	return json.Marshal(alias)
+}
+
 // Pet defines model for Pet.
 type Pet struct {
 	// Unique id of the pet.GET(baseURL+"/pets", wrapper.GetPets)

空のスライスでの初期化後は json.Marshal のデフォルトの挙動に任せるようにしており、type Alias GetPetsResponse することによって別の型として扱い、 GetPetsResponse#MarshalJSON が再帰的に呼び出されるのを防いでいます。

改めてサーバーを起動し、リクエストを送ってみます。

$ curl localhost:8080/pets
{"pets":[]}

無事に [] を返却することができました。

まとめ

この記事では、oapi-codegen の -templates オプションを利用して、ユーザー定義のカスタムテンプレートを利用することで、OpenAPI で requied array と定義したプロパティが null として返却されてしまう問題を解決しました。

カスタムテンプレートを利用することはメリットばかりではなく、テンプレートをメンテナンスするコストがかかってくることに注意してください。oapi-codegen を更新した際にはデフォルトのテンプレートの変更に追従していく必要があり、他のテンプレートによって MarshalJSON が定義されるようになれば、うまく内容をマージして単一の MarshalJSON を定義するようにしなければなりません。また、この方法で required array が null で返却されることを防いでいて何か問題が発生した場合、導入前の状態に戻すことは難しいです。これを取り除くことによって今まで [] を返していた required array が null を返すようになる可能性があります。

以上のリスクはありますがとても便利で確実な方法ではあるので、同じ問題で困っている方は(自己責任で)導入してみてください。

  1. -templates オプションについて README.md にも記載があったので、実装を読む前にドキュメントに軽く目を通すべきでした。 https://github.com/deepmap/oapi-codegen/tree/32316b9c067a927466db3d16958f290cba4841b8#making-changes-to-code-generation

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?