LoginSignup
13
4

More than 1 year has passed since last update.

openapi-generator で自作テンプレートを用いて iOS API クライアントを自動生成した話

Posted at

この記事は ドワンゴ Advent Calendar 2022 11 日目の記事です。1日遅刻してしまいました :bow:

こんにちは、@tasuwo です。普段はニコニコ動画 iOS アプリの開発に携わっています。ニコニコ動画iOSアプリ (以下、動画iOSアプリ) では3年程前から OpenAPI 仕様を元に APIクライアントを自動生成し、動画iOSアプリとボカコレアプリで利用しています。その中で得られた知見について紹介しようと思います。(そもそも OpenAPI とは?という話は、今回は割愛します :pray: )

コード自動生成導入の経緯

主なモチベーションはやはり省力化でした。導入以前は仕様書を元に実装を行い、同じ仕様書を元にレビューするといった工程が必要でしたが、仕様書からコードが自動生成できれば多くの手間を省くことができます。
また、動画iOSアプリから主に利用するAPIでは既にOpenAPI仕様が提供されていました1。この仕様を提供するチームは動画iOSアプリの開発チームとは別チームでしたが、自動生成に関する相談に積極的に乗っていただける環境であったことも大きいです。
ついでに、当時のAPIクライアントライブラリはObjective-C製であり、Swift製に移行することを検討している最中でした。折角なら自動生成するようにして、ついでにSwift製にしてしまおうという狙いもありました。

ツールの選定と方針

OpenAPI 仕様からのコード生成ツールとして有名で保守も活発なのは openapi-generator かと思われます。保守の活発さとドキュメントの充実度から、動画iOSアプリチームでもこちらのツールを採用していますが、少し工夫を加えています。
openapi-generator のデフォルトの Swift テンプレートは、生成結果のインタフェースがチームの要求に合いませんでした。また、チームではモック自動生成ツールによりユニットテスト用のモックの自動生成も既に行っており2、モックも生成対象に含めたいという要求がありました。そのため、デフォルトのテンプレートをそのまま利用するのではなく、チームの要求に合わせて生成内容を調整できないか?検討することになりました。
openapi-generator は、OpenAPI仕様を読み取りその内容を正規化するコア部分、正規化されたデータを各言語のコード生成用のデータにマッピングするジェネレータ部分、ジェネレータのマッピング結果をどのようにコードに反映するかを記述したテンプレート部分に大別できます。ジェネレータは言語毎に用意されており、各々型のマッピングや利用するテンプレートファイル、出力先等の設定内容を含んでいます。
自動生成結果を調整したい場合、一番手っ取り早いのはテンプレートを自作することです。テンプレート言語にはデフォルトで3 mustache が採用されていますが、これは仕様がかなり簡素で学習コストも低めです。ジェネレータ自体を自作することもできますが、既存のSwift用ジェネレータで十分そうであったため、テンプレートのみを自作する方針で進めることになりました。

openapi-generator のテンプレートの背景知識

テンプレート自作についての日本語情報が少なそうだったので、自作のために必要そうな知識と自作の手順について軽く紹介したいと思います。

mustache テンプレートについて

以降の説明のために、mustache の仕様について軽く触れておきます。まず、テンプレートは以下のように記述できます。

Hello {{name}}

上記テンプレートに以下のようなハッシュを与えると、

{
  "name": "tasuwo"
}

以下のような生成結果となります。

Hello tasuwo

これは {{}} で囲んだ箇所を置き換える単純なテンプレートですが、条件分岐などの機能も存在します。詳しくは仕様を参照してください。
openapi-generator 用のテンプレートを自作する場合には、どのような種類のテンプレートを用意すれば良いのか?与えられるハッシュの内容がどのようなものか?がわかれば十分そうです。

openapi-generator の生成対象

openapi-generator が生成対象として定義しているのは、現時点だと以下の7種類です4

  • API: OpenAPI仕様における operation に対応するコード生成内容を記述する。APIクライアントのインタフェースはここに属する
  • APIDocs: APIのドキュメント
  • APITests: APIのテスト
  • Model: OpenAPI仕様上で定義された各種モデルに対するコード生成内容を記述する。リクエスト/レスポンスモデル定義などはここに属する
  • ModelDocs: Modelのドキュメント
  • ModelTests: Modelのテスト
  • SupportingFiles: 上記以外の種類のファイル

上記の生成対象毎に異なるテンプレートファイルを用意する必要があり、生成対象とテンプレートファイル名の組み合わせは、基本的にはジェネレータ内に直接定義されたものが使用されます5
例えば、Swift用ジェネレータの実装を確認すると、API用のテンプレートは api.mustache、Model用のテンプレートは model.mustache、APIDocs用のテンプレートは api_doc.mustache、ModelDocs用のテンプレートは model_doc.mustache であることがわかります。

modelTemplateFiles.put("model.mustache", ".swift");
apiTemplateFiles.put("api.mustache", ".swift");
// ...
modelDocTemplateFiles.put("model_doc.mustache", ".md");
apiDocTemplateFiles.put("api_doc.mustache", ".md");

https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift5ClientCodegen.java#L120-L126

各言語のジェネレータは現状 こちら から確認できます。

openapi-generator からテンプレートに与えられるデータ

openapi-generatorはOpenAPI仕様の記載内容を正規化します。この内容を各言語用のジェネレータがマッピングした結果が、テンプレートにハッシュとして受け渡されます。そのため、テンプレートに受け渡されるハッシュの内容は、ジェネレータ毎に異なります。また、各テンプレートに全てのデータが渡されるわけではなく、渡されるデータ構造もテンプレートの種類毎に異なっています。
データ構造には、現状以下の種類があります6

  • Operations: OpenAPI仕様上に定義されたすべての operation 定義
  • Models: OpenAPI仕様上に定義されたすべてのモデル定義
  • supportingFiles: OpenAPI仕様上の全定義 (OperationsおよびModelsの内容を含む)

生成対象とテンプレートに受け渡されるデータ構造の組み合わせは、下記のようになります。

生成対象 データ構造
API Operations
APIDocs Operations
APITests Operations
Model Models
ModelDocs Models
ModelTests Models
SupportingFiles supportingFiles

テンプレート自作の進め方

まずは生成対象を決定し、生成対象に受け渡されるデータ構造を確認しながら mustache テンプレートを記述していくことになります。

生成対象を限定する

動画iOSアプリではAPIからAPIクライアントを、Modelから各種モデル定義を生成します。そのほかの APIDoc, APITests, ModelDocs, ModelTests, SupportingFiles は生成したくないので、生成対象から外す必要があります。openapi-generator には、生成時に受け渡せる設定値として Global Properties が定義されています。APIおよびModelのみを生成したい場合は、models および apis を指定します。

openapi-generator generate --global-property=models,apis

テンプレートを記述する

Swift用ジェネレータではAPIはapi.mustache、Modelsはmodel.mustacheを入力とするため、この2つのテンプレートファイルを用意すれば良いです。
一からテンプレートを記述していくのはなかなか大変なので、既存のテンプレートをベースに調整していくのが楽です。各言語のテンプレートは resources 以下に配置されています。

https://github.com/OpenAPITools/openapi-generator/tree/v6.2.1/modules/openapi-generator/src/main/resources

テンプレートを記載していく中で、ハッシュとして受け渡される内容を知りたくなるケースがあります。このような場合には、テンプレートに受け渡されるデータ構造を確認するための Global Peoperty が利用できます。それぞれ、以下のように指定します。

# Operationsの内容を出力したい場合
openapi-generator generate -g go \
    -o out \
    -i my-spec.yaml \
    --global-property debugOpenAPI=true

# Modelsの内容を出力したい場合
openapi-generator generate -g go \
    -o out \
    -i my-spec.yaml \
    --global-property debugModels=true

# supportingFilesの内容を出力したい場合
openapi-generator generate -g go \
    -o out \
    -i my-spec.yaml \
    --global-property debugSupportingFiles=true

これらのコマンドを実行すると、テンプレートに受け渡されるハッシュがそのまま出力されます。コードの自動生成元となるOpenaPI仕様が用意できたら、仕様に対して上記コマンドを実行し、テンプレートに受け渡されるデータを確認しながらテンプレートを修正することができます。

ハマったこと/工夫したこと等

型のマッピングを行う

OpenAPI上の型と各言語の型とのマッピングはジェネレータ上で行われますが、このマッピング定義を変更したいケースがあります。ジェネレータを自作すればマッピング自体を再定義できますが、既存のジェネレータから出力される一部の型を別の型に置き換えたいのみである場合には、Type Mappings が利用できます。
動画iOSアプリでは、桁数の多い数値はDoubleで扱えた方が都合が良かったので、以下のようにしてFloatDoubleに変更しています。

openapi-generator-cli generate \
    -i template.yml \
    -g swift5 \
    -o out \
    --type-mappings=Float=Double

難があるEnumの判定に対応する

OpenAPI仕様としては正しく記述されていても、openapi-generatorがそれを正しく解釈できないケースが稀に存在します。Enumの判定がその一つです。
あるモデルが Enum であるかどうか?は mustache テンプレート上で isEnum を利用して判定できることになっていますが、Enum をインラインではなく独立した定義として切り出してしまうと、isEnum が常に false になってしまう、という既知の問題があります。
具体的には、下記のような API 仕様の時、paramAisEnum=falseparamBisEnum=true として解釈されてしまいます。

openapi: '3.0.0'
info:
  version: 1.0.0
  title: Enums Issue
paths:
  /test:    
    get:
      operationId: TestEnum
      parameters:
        - name: paramA
          in: query
          required: true
          schema:
            $ref: '#/components/schemas/EnumString'
        - name: paramB
          in: query
          required: true
          schema:
            type: string
            enum:
              - first
              - second
              - third

components:
  schemas:
    EnumString:
      type: string
      enum:
        - first
        - second
        - third

これは、Enum をインラインで定義した場合と別のオブジェクトを参照した場合では、OpenAPI仕様的には同じでもopenapi-generator的には異なるものとして扱っている関係で生じてしまっている問題であり、複雑な事情から解決が難しいようです。

https://github.com/OpenAPITools/openapi-generator/issues/2645

そのため、Enum かどうかの判定には isEnum ではなく、allowableValues が空かどうかを利用すると良いと提案されています。また、Enum が Array の要素であるか区別するために isListContainer と組み合わせて判定することができます。

{{#vars}}
  {{^isListContainer}}
    {{#allowableValues}}
      {{^enumVars.empty}}
        {{! Enum かつ Array でない }}
      {{/enumVars.empty}}
    {{/allowableValues}}
  {{#isListContainer}}
    {{#allowableValues}}
      {{^enumVars.empty}}
        {{! Enum かつ Array である }}
      {{/enumVars.empty}}
    {{/allowableValues}}
  {{/isListContainer}}
{{/vars}}

https://github.com/OpenAPITools/openapi-generator/issues/2645#issuecomment-521712466
https://github.com/OpenAPITools/openapi-generator/issues/2645#issuecomment-523325256

クライアントサイドの都合で仕様書を書き換える

OpenAPI仕様は、基本的にはサーバサイドのチームが記述したものを利用していますが、クライアントサイドの事情で一部仕様を書き換えたいケースがありました。これは、例えば以下のようなケースです。

  • 特定のパラメータ定義を無視したい
    • コード自動生成内容の都合上、API間で共通で付与するパラメータ定義は共通して設定する口を設けるために、APIクライアント毎に設定する口を設けたくない場合
  • 型を差し替えたい
    • コード自動生成の都合上、特定のモデルの型を差し替えたい場合

OpenAPI仕様はyamlファイルですが、OpenAPI仕様としてのコードを意識しつつ書き換えたい場合には、単なるYamlパーサよりもswagger-parserを利用した方が便利でした。
例えば、特定のパラメータ定義を無視する場合のコードは、以下のように記述できます。

const SwaggerParser = require('swagger-parser')
const yaml = require('js-yaml')
const fs = require('fs')

const srcFile = // ...
const dstFile = // ...

async function main() {
  try {
    const api = await SwaggerParser.parse(srcFile)

    ignoreParameters(['#/components/parameters/AppVersion'], api)

    fs.writeFileSync(dstFile, yaml.dump(api))
  } catch (err) {
    console.error(err)
    process.exit(1)
  }
}

function ignoreParameters(params, api) {
  for (var [path, pathValue] of Object.entries(api.paths)) {
    // 下記のように、operation以下に定義されているパラメータを除外する
    //
    // get:
    //   parameters:
    //     - $ref: '...'
    for (var [operation, operationValue] of Object.entries(pathValue)) {
      if (operationValue.hasOwnProperty('parameters')) {
        operationValue.parameters = operationValue.parameters.filter(
          (param) => (param.hasOwnProperty('$ref') && params.includes(param['$ref'])) == false
        )
      }
    }
    // 下記のように、path以下に定義されているパラメータを除外する
    //
    // paths:
    //   /v1/hoge:
    //     parameters:
    //       - $ref: '...'
    if (pathValue.hasOwnProperty('parameters')) {
      pathValue.parameters = pathValue.parameters.filter(
        (param) => (param.hasOwnProperty('$ref') && params.includes(param['$ref'])) == false
      )
    }
  }
}

main()

まとめ

openapi-generator をテンプレートを自作して利用する方法の簡単な紹介と、利用する中で得た知見の一部について紹介しました。
今の所、Swift5のジェネレータと自作テンプレートの組み合わせで運用が続いています。テンプレートの保守は属人的にならないよう、保守のためのドキュメントを充実させており、特定のチームメンバーのみが保守するような事態に陥ることは避けられています。今では新規のAPIクライアントはほぼOpenAPI仕様から自動生成されており、かなりの手間を省くことができました。しかし、APIによってはOpenAPI仕様が提供されていないケースもあります。OpenAPI仕様を提供してもらえるようAPI提供元のチームにお願いできることが理想ですが、難しい場合にはiOSアプリチーム内でOpenAPI仕様を書き下しています。そのような場合にはOpenAPI仕様に対するレビューをチーム内で行う必要があります。
テンプレートを自作した場合の利点は、チーム内の要求に柔軟に対応できることです。今回は紹介を省いていますが、自作のおかげで、oneOfやdiscriminatorなど元のテンプレートでは対応していなかった機能への対応や、Swift Concurrencyを活用するための対応なども迅速に行うことができました。ただし、稀に Enum の判定のような openapi-generator 特有の罠にハマることもあります。ジェネレータの出力内容に問題がありPRを出したケースもありました。
総合的には、コストをメリットが上回っており、OpenAPIによる自動生成および自作テンプレートの導入は良かったなと感じています。

  1. こちらで紹介されているものです

  2. 今回は割愛しますが、mockoloSourceryを利用してモックを生成しています

  3. version 4.0 から、Handlebarsやその他テンプレートエンジンへの対応がexperimentalな機能として提供されています (参考)

  4. https://openapi-generator.tech/docs/customization#user-defined-templates

  5. version 5.0 から、設定ファイルにて生成対象とテンプレートファイルを紐づけることができるようです (参考)

  6. https://openapi-generator.tech/docs/templating#structures

13
4
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
13
4