OpenAPIスキーマから生成されるコードに任意の型を指定する

こんにちは、HRBrainでバックエンドエンジニアをしている鈴木(善)です。 今回は、OpenAPIのスキーマからGoのコードを生成する際に、任意の型を指定できるよう拡張した話を紹介したいと思います。
なお、こちらは HRBrain Advent Calendar 2020 18日目の記事です。

拡張を必要とした背景

弊社では、現在スキーマ駆動での開発を進めています。まずスキーマを定義し、そこからインターフェースとなるコードを自動生成するアプローチです。 私の担当するサービスでは、RESTfulなAPIではあるもののスキーマ駆動にはできていなかったため、ここを改善していくこととしました。
(歴史の長いサービスなどでは、あとからスキーマ駆動に対応させる今回のようなケースはあるかなと思います)

スキーマの仕様は OpenAPI Specification(以下OpenAPI)を使い、サーバー(Go製)向けのコードジェネレーターについては oapi-codegen を採用しました。 github.com

詳細は割愛しますが、このジェネレーターを採用したポイントとしては:

  • chi 向けのコードが生成でき、実装済みの既存のchiのルーティングと共存できたこと。これにより既存のものを全置き換えしなくても、部分的なところからスキーマ駆動の開発に切り替られる。
  • Go向けのジェネレータということもあり、Goで実装しやすいインターフェースや型のコードが生成される。
  • 生成されるコードが比較的薄く把握しやすい。

などがありました。

今回検討したサービスでは、IDを表す型としてuint64を使っていました。ただ、OpenAPIのData Typesではこれは仕様の範囲外です。そのため、oapi-codegenでもuint64型でコードを生成することはできません。 この問題の対応としては、「性能面も考慮しつつstring⇔uint64で変換する」や「桁あふれのリスクを考慮しつつint64を使う」なども検討しましたが、「素直にuint64でコード生成できれば余計なこと考えなくていいよね」ということで、その可能性を探っていきました。
(個人的には、自分たちが本当に嬉しいこと(楽になること)を考え、追求していける雰囲気や文化があるのは、弊社のよいところだなぁと感じています)

どのように拡張したか

OpenAPIにあるSpecification Extensionsという仕様を利用しました。x-から始まる独自のフィールドを追加できる拡張用の仕様です。 oapi-codegenにPRを出し、x-go-typeというフィールドに型名を指定しておくと、oapi-codegenがその型名でコードを生成するようにしました。 github.com

OpenAPIの拡張仕様に則っているため、OpenAPIのlinterからも怒られることもありません。

どのように使うのか

基本編

実際に、IDを示すフィールドにuint64型を指定したものがこちらです。

openapi: 3.0.0
# ...(略)...
components:
  schemas:
    News:
      title: お知らせ
      type: object
      properties:
        id:
          type: integer
          x-go-type: uint64 # ←ここに型名を指定
#...(略)...

oapi-codegenで生成すると次のようなGoのコードが生成されます。

//...(略)...

// News defines model for News.
type News struct {
    Id        uint64    `json:"id"` // ←指定したuint64型で自動生成されている
    Text      string    `json:"text"`
}

さらに、これをコンポーネントとしてスキーマ内で切り出しておくと、x-go-typeの記述は1箇所に留められるため、拡張の記述が散らばることも避けられます。
下記の例ではIDというコンポーネントに切り出しています。

openapi: 3.0.0
# ...(略)...
components:
  schemas:
    ID:
      type: integer
      title: ID
      x-go-type: uint64 #←拡張についてはここ1箇所に記述
    News:
      title: お知らせ
      type: object
      properties:
        id:
          $ref: '#/components/schemas/ID' #←他からはIDを参照するだけ
        text:
          type: string
# ...(略)...
  parameters:
    NewsIDPathParam:
      in: path
      name: newsID
      required: true
      schema:
        $ref: '#/components/schemas/ID' #←他からはIDを参照するだけ

切り出したIDDefined Typeとして生成されます。

// ...(略)...

// ID defines model for ID.
type ID uint64

// News defines model for News.
type News struct {
    Id        ID        `json:"id"` // ←ここはuint64ではなくID型となる
    Text      string    `json:"text"`
}

// ...(略)...

応用編

json.Marshalerjson.Unmarshalerを実装した独自の型を指定することも可能です。 (ただし制約として、独自に定義する構造体は自動生成されるコードと同じにパッケージ入れておく必要があります)

components:
  schemas:
    News:
      title: お知らせ
      type: object
      properties:
        id:
          $ref: '#/components/schemas/ID'
        text:
          type: string
        custom_date:
          type: string
          x-go-type: CustomDate #←日時を表す独自の型をスキーマとして定義

独自の構造体の例:

package api

import (
    "encoding/json"
    "time"
)

type CustomDate struct {
    time time.Time
}

func (d *CustomDate) UnmarshalJSON(b []byte) error {
    // your unmarshal logic...
    d.time = time.Now()
    return nil
}

func (d CustomDate) MarshalJSON() ([]byte, error) {
    // your marshal logic...
    return json.Marshal(d.time.Format(time.ANSIC))
}

連携先の都合など個別の事情で、独自フォーマットを受け付けないといけないような場合でも、スキーマ駆動にしつつ対応することができます。

まとめ

OpenAPIのスキーマ定義に型を指定できるようになったことで、既存のコードベースを大改修することなく、緩やかにOpenAPIベースのスキーマ駆動開発に移行していけるようになりました。スキーマ駆動開発を検討されている方の参考になれば幸いです!