ogenでOpenAPI定義からGoのAPIサーバ基盤コードを自動生成する

ATOM開発チームの上野です。 普段はGo言語を使ってAPIサーバやバッチ処理機構の実装などを担当しています。

今回はATOMのプロジェクトでAPIサーバのコード生成に新しく導入した
ogen というパッケージをご紹介します。

GoでOpenAPI定義からAPIハンドラなどの基盤コードを自動生成するには様々なツールがありますが、
その中でも当時は go-swagger を使用していました。

go-swaggerの使用感自体は悪くなかったのですが、
go-swaggerは OpenAPI2.0(Swagger2.0)にしか対応しておらず、
OpenAPI3.0に移行するために乗り換え先を探したところ、ogen-go/ogen に出会いました。

ogenには以下の特徴があります

  • OpenAPI3.0に対応している
  • 生成コードがシンプルで、(HTTPサーバを実装する上では)無駄がなく、生成も早い
  • 開発が盛んで、ドキュメントも見やすい

ogen を使用したサンプルプロジェクトは以下で公開されています。
https://github.com/ogen-go/example

自動生成

生成コマンド

$ ogen -target ./pkg/openapi/ -package openapi -clean ./openapi.yml

出力コード (Quick startの内容を抜粋しています)

API定義

openapi: 3.0.2
servers:
  - url: /v3
info:
  version: 1.0.0
  title: Pet store schema
tags:
  - name: pet
    description: Everything about your Pets
paths:
  /pet:
    post:
      tags:
        - pet
      summary: Add a new pet to the store
      description: Add a new pet to the store
      operationId: addPet
      responses:
        '200':
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
      requestBody:
        description: Create a new pet in the store
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
  '/pet/{petId}':
    get:
      tags:
        - pet
      summary: Find pet by ID
      description: Returns a single pet
      operationId: getPetById
      parameters:
        - name: petId
          in: path
          description: ID of pet to return
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        '404':
          description: Pet not found
      responses:
        '200':
          description: successful operation
components:
  schemas:
    PetStatus:
      type: string
      description: pet status in the store
      enum:
        - available
        - pending
        - sold
    Pet:
      required:
        - name
      properties:
        id:
          type: integer
          format: int64
          example: 10
        name:
          type: string
          example: doggie
        photoUrls:
          type: array
          items:
            type: string
        status:
          $ref: '#/components/schemas/PetStatus'

スキーマ

// Ref: #/components/schemas/Pet
type Pet struct {
    ID        OptInt64     `json:"id"`
    Name      string       `json:"name"`
    PhotoUrls []string     `json:"photoUrls"`
    Status    OptPetStatus `json:"status"`
}

// GetID returns the value of ID.
func (s *Pet) GetID() OptInt64 {
    return s.ID
}
...

Handler interface

type Handler interface {
    // AddPet implements addPet operation.
    //
    // Add a new pet to the store.
    //
    // POST /pet
    AddPet(ctx context.Context, req *Pet) (*Pet, error)
    // GetPetById implements getPetById operation.
    //
    // Returns a single pet.
    //
    // GET /pet/{petId}
    GetPetById(ctx context.Context, params GetPetByIdParams) (GetPetByIdRes, error)
}

実装

type Handler struct {
    oas.UnimplementedHandler // automatically implement all methods
}

func (h Handler) GetPetById(ctx context.Context, params oas.GetPetByIdParams) (oas.GetPetByIdRes, error) {
    return &oas.Pet{
        ID:     oas.NewOptInt64(params.PetId),
        Name:   fmt.Sprintf("Pet %d", params.PetId),
        Status: oas.NewOptPetStatus(oas.PetStatusAvailable),
    }, nil
}

func main() {
    oasServer, err := oas.NewServer(Handler{})
    if err != nil {
        //
    }
    httpServer := http.Server{
        Addr:    arg.Addr,
        Handler: oasServer,
    }
    if err := httpServer.ListenAndServe(); err != nil {
        //
    }
}

このように生成コードは単純で、生成されたinterfaceに従って実装するだけになっています。
リクエストパラメータ、レスポンス、enumなど全て型が与えられており、実装に迷うことはなさそうです。
また、これに加えて自作のミドルウェアを差し込むことも可能になっています。

フィールドの生成型

Pet.IDOptInt64 という型になっていますが、こちらも自動生成された型になります。
ogenでは、OpenAPI定義の required nullable によって以下のように生成型が変わります。

required nullable
true false string
false false OptString
true true NilString
false true OptNilString

OptXXX 型には IsSet()NilXXX 型には IsNull() の関数が生成されるので
クライアントからのリクエストを厳密に扱うことになります。
例えばリクエスト {}{"data": null} どちらも "data" というフィールドに値が存在しない場合ですが、
OptXXX の型では {"data": null} を受け取ることができず、必ずAPI定義で nullable:true を設定し、
OptNilXXX の型で受け取らなければなりません。

この厳密さは時折不便と感じるポイントかもしれません。
実際にフロントアプリケーション側で nullable を設定していないのに
{"data": null} のように明示的にnullを送ってしまうことはあると思いますが、
その度にバリデーションエラーが発生してしまうのはどうなんだろう、という感想です。

まとめ

ogenを導入しましたが、個人的には良かったかなと思っています。
OpenAPIから自動生成するツールの中で、必要なものが揃っている上で特に減点ポイントがない印象でした。
特にOpenAPI3.0を使う場合はぜひ参考にしてみてください!