はじめに
私が今関わっているプロジェクトでは、サーバーサイドに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: "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
// 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
が格納されることに注意してください。
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 などを参照してください)。
{{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
を返すようになる可能性があります。
以上のリスクはありますがとても便利で確実な方法ではあるので、同じ問題で困っている方は(自己責任で)導入してみてください。
-
-templates
オプションについてREADME.md
にも記載があったので、実装を読む前にドキュメントに軽く目を通すべきでした。 https://github.com/deepmap/oapi-codegen/tree/32316b9c067a927466db3d16958f290cba4841b8#making-changes-to-code-generation ↩