yoshikipom Tech Blog

oapi-codegenを使ってクリーンで仕様ズレの起きないGo言語API開発

解決したい課題

OpenAPIでのspecとコード開発が分離されていると以下のような課題がある

  • 利用者が見ると仕様と実装のズレが起きる可能性
  • ズレが起きないとしても開発時の仕様確認や慎重なレビューが面倒

oapi-codegenを使うことでspec通りの実装を強制できる。 https://github.com/deepmap/oapi-codegen/tree/master

oapi-codegenを使ったAPI開発のステップ

  • (A) OpenAPI定義を作成
  • (B) oapi-codegen で (A) からGo言語APIのコード生成
    • 生成されたコードは、APIのリクエストとレスポンスの型安全性を保証
  • (C) (B)に含まれるサーバのインターフェース(StrictServerInterface)を実装
  • (D) main関数から(C)をリクエストハンドラとして利用しているWebフレームワークに登録

ここでは以下の条件に沿って例を示す。

  • oapi-codegenのexampleにあるOpenAPI仕様を利用
  • Webフレームワークはechoを使用

今回の完成品の全体感は以下。

.
├── api // API controllers
│   ├── api.gen.go // (B)generated API handler + server interface
│   ├── api.go // (C) server implementation
│   ├── server.yml // (B)config to generate api.gen.go
│   ├── type.gen.go // (B) generated types
│   └── type.yml // (B) config to generate type.gen.go
├── go.mod
├── go.sum
├── main.go (D)
└── openapi.yaml // (A) source of api.gen.go & type.gen.go

コードは以下に置いてある。 https://github.com/yoshikipom/go/tree/main/oapi-codegen

(A) OpenAPI定義を作成

今回は以下をそのまま使う。自分で書くならVS Codeの拡張やStoplight Studioを使う。 https://github.com/deepmap/oapi-codegen/blob/master/examples/petstore-expanded/petstore-expanded.yaml

(B) oapi-codegen で (A) からGo言語APIのコード生成

yamlファイルでoapi-codegenの生成ルールを定義 server.yml

package: api
generate:
  echo-server: true
  strict-server: true
  embedded-spec: true
output: api/api.gen.go

type.yml

package: api
generate:
  models: true
output: api/type.gen.go

これらをoapi-codegenで読み込んで API関連のコードであるapi/api.gen.go と リクエスト・レスポンスなどの型情報であるapi/type.gen.go を生成。

go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest
oapi-codegen -config api/type.yml openapi.yaml
oapi-codegen -config api/server.yml openapi.yaml

(C) (B)に含まれるサーバのインターフェース(StrictServerInterface)を実装

自分はVSCodeの拡張でコードを生成してから中身を埋めている。

VSCodeのInterface Stubを生成する機能

receiver名、構造体の型、実装したいinterfaceを渡す

生成されるコード

// Returns all pets
// (GET /pets)
func (ss *MyStrictServer) FindPets(ctx context.Context, request api.FindPetsRequestObject) (api.FindPetsResponseObject, error) {
    panic("not implemented") // TODO: Implement
}

// Creates a new pet
// (POST /pets)
func (ss *MyStrictServer) AddPet(ctx context.Context, request api.AddPetRequestObject) (api.AddPetResponseObject, error) {
    panic("not implemented") // TODO: Implement
}

...

中身の実装例。RequestBodyがAddPetRequestObject.Bodyにbindingされていることや、Pet型を戻り値として渡せることから型安全かつ仕様通りに実装できることがわかる。また、このレイヤですでにWebフレームワークに依存しない実装になっているのも良い。

func (ss *MyStrictServer) AddPet(ctx context.Context, request AddPetRequestObject) (AddPetResponseObject, error) {
    var pet Pet
    pet.Name = request.Body.Name
    pet.Tag = request.Body.Tag
    pet.Id = ss.NextId
    ss.NextId = ss.NextId + 1

    ss.Pets[pet.Id] = pet

    return AddPet200JSONResponse(pet), nil
}

(D) main関数から(C)をリクエストハンドラとして利用しているWebフレームワークに登録

  • e.Use(middleware.OapiRequestValidator(swagger)) でリクエストのバリデーションを行うmiddlewareを登録
  • api.RegisterHandlers(e, h) でechoに(C)で実装したハンドラを登録
package main

import (
    "flag"
    "fmt"
    "os"

    "github.com/deepmap/oapi-codegen/pkg/middleware"
    "github.com/labstack/echo/v4"
    echomiddleware "github.com/labstack/echo/v4/middleware"
    "github.com/yoshikipom/go/oapi/api"
)

func main() {
    var port = flag.Int("port", 8080, "Port for test HTTP server")
    flag.Parse()

    swagger, err := api.GetSwagger()
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error loading swagger spec\n: %s", err)
        os.Exit(1)
    }
    swagger.Servers = nil

    e := echo.New()
    e.Use(echomiddleware.Logger())
    e.Use(middleware.OapiRequestValidator(swagger))

    ss := api.NewMyStrictServer()
    h := api.NewStrictHandler(ss, []api.StrictMiddlewareFunc{})
    api.RegisterHandlers(e, h)

    e.Logger.Fatal(e.Start(fmt.Sprintf("0.0.0.0:%d", *port)))
}

動作確認

サーバサイド

yoshiki@yoshiki-mbp:go/oapi-codegen ‹main›$ go run main.go                                    

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.10.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8080
{"time":"2023-07-04T23:39:23.773179+09:00","id":"","remote_ip":"127.0.0.1","host":"localhost:8080","method":"POST","uri":"/pets","user_agent":"vscode-restclient","status":200,"error":"","latency":121657,"latency_human":"121.657µs","bytes_in":43,"bytes_out":41}

クライアント

yoshiki@yoshiki-mbp:go/oapi-codegen ‹main›$ curl --request POST \
  --url http://localhost:8080/pets \
  --header 'content-type: application/json' \
  --header 'user-agent: vscode-restclient' \
  --data '{"name": "Alice","tag": "test2"}'
{"id":1001,"name":"Alice","tag":"test2"}

OpenAPI Generator はどうか

OpenAPI Generator https://github.com/OpenAPITools/openapi-generator これ一つでOpenAPI定義から多様な言語のサーバー側、クライアント側のコードを生成できる優れもの。 ただ、保守性の面で不安がある。oapi-codegen の場合は生成されたファイルに手を加える必要がないが、OpenAPI Generatorの場合は生成されたファイルに手を加えて拡張する必要がある。例えば、main.goも生成されるが、middlewareの追加などを手動で行うことはよくあるので手動での変更は必須である。これだと仕様変更時にファイルを再生成場合、手動でマージする必要が出てしまう。 Go言語のAPI開発だけ考えるのであれば oapi-codegen を選択する。

感想

クライアントサイドの生成と違って、サーバサイドの生成はメンテが難しそうで抵抗があったが、このツールで生成したコードはロジックの実装と完全に分離されているので非常に使いやすそう。REST APIを新規開発する機会があれば導入してみたい。