Help us understand the problem. What is going on with this article?

openapi-generator をカスタマイズする方法

More than 1 year has passed since last update.

この記事はオプトテクノロジーズアドベントカレンダー(2018) 9 日目のエントリーです。

前書き

ウェブアプリケーションを作ると API とその仕様のドキュメントを用意することが多いと思います。最近は API の仕様をある規格にしたがって書き下し、それを元にクライアントを自動生成するような仕組みも広まってきています。

この記事では、そのような規格の一つである Open API を使ってクライアントを自動生成し、その出力結果をカスタマイズする方法について紹介します。

OpenAPI とは

正確なところは下記のリンク先を参照していただくとして、大まかなところでいうと REST API の入出力を記述するための仕様です。

元々は Swagger という名前でしたが、現在は OpenAPI という名前になっています。その流れで、関連ツールは swagger-* という命名のものも多いです。

openapi-generator

OpenAPI Document から API のクライアントやスタブ実装を生成するツールでメジャーなものとして swagger-codegenopenapi-generator の 2 つがあります。

ここでは後者の openapi-generator のカスタマイズ方法について記載していきます。

カスタマイズ

さて、いよいよ本題です。

基本はQuramy さんの記事を参考にして書いています。

swagger-codegen の話ですが現時点ではそこまで乖離もないので眺めてもらうと generator カスタマイズの基本がわかると思うのでまずはご一読ください。

そうです。 openapi-generator では mustache を採用しているので条件分岐などはできないのです1。そんなに多くはないですが稀に条件分岐したいこともあるので今回はそんなケースに立ち向かっていきます。

まずは自前の Generator を作ってみる

イメージを掴むためほぼ継承元の挙動を変更しないようにして自前の Generator を作ってみます。

gen1.groovy
@Grab(group = 'org.openapitools', module = 'openapi-generator-cli', version = '3.3.4')

import org.openapitools.codegen.*
import org.openapitools.codegen.languages.*

class MyCodegen extends TypeScriptNodeClientCodegen {

  static main(String[] args) {
    OpenAPIGenerator.main(args)
  }

  MyCodegen() {
    super()
  }

  String name = "my-codegen"
}

MyCodegen.main(args)

みての通りほとんど何も変更をしていません。これに公式のサンプルを与えてみます。

$ wget "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/3.0.2/examples/v3.0/petstore.yaml"
$ ls
gen1.groovy   petstore.yaml
$ groovy \
  ./gen1.groovy \
  generate \
  -i ./petstore.yaml \
  -g MyCodegen \
  -o ./output
$ tree output
output
├── api
│   ├── apis.ts
│   └── petsApi.ts
├── api.ts
├── git_push.sh
└── model
    ├── modelError.ts
    ├── models.ts
    └── pet.ts

2 directories, 7 files

テンプレートのカスタマイズ

一から Generator を作っても良いのですが、ここでは MyCodegen をそのまま使っていきます。

まずはテンプレートをコピーします。(方法はなんでも良いです)

$ BASE_URL="https://raw.githubusercontent.com/OpenAPITools/openapi-generator/v3.3.4/modules/openapi-generator/src/main/resources/typescript-node"
$ mkdir template && cd template
$ wget "$BASE_URL"/gitignore
$ wget "$BASE_URL"/{api-all,api-single,api,git_push.sh,licenseInfo,model,models,package,tsconfig}.mustache

template フォルダ配下に typescript-node のテンプレートがコピーできたら、以下のようにして実行します。

$ rm -rf ./output
$ groovy \
  ./gen1.groovy \
  generate \
  -i ./petstore.yaml \
  -g MyCodegen \
  -t ./template \
  -o ./output

先ほどとの違いは -t 引数にテンプレートが置かれているディレクトリを指定したかどうかです。

カスタマイズしようとしている内容がテンプレートの修正で達成できるのであれば、ここまでわかっていれば問題ありません。

Generator のカスタマイズ

さていよいよ本丸です。まずは次の OpenAPI Document をご覧ください。

openapi: "3.0.0"
info:
  version: 1.0.0
  title: random
paths:
  /random:
    get:
      operationId: random
      responses:
        default:
          description: "いろんな型が返ってくる"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Random"
components:
  schemas:
    Random:
      oneOf:
      - $ref: "#/components/schemas/TypeA"
      - $ref: "#/components/schemas/TypeB"
    TypeA:
      required:
        - type
        - name
      properties:
        type:
          type: string
        name:
          type: string
    TypeB:
      required:
        - type
        - age
      properties:
        type:
          type: string
        name:
          type: string

注目していただきたいのは Random の定義です。 oneOf を用いて定義されています。 現状の openapi-generator のコードではこれをうまく処理することができません2

幸いなことに TypeScript ではそういった機能(Union Types)が用意されているためテンプレートでうまく使ってやれば簡単に対応できそうです。
そうは言うものの openapi-generator には特定のモデルだけ別のテンプレートを使うなどといった機能は用意されていないので、かなり大胆なことをする必要があります。

gen2.groovy
@Grab(group = 'org.openapitools', module = 'openapi-generator-cli', version = '3.3.4')

import groovy.transform.EqualsAndHashCode
import groovy.transform.stc.ClosureParams
import groovy.transform.stc.FromString
import org.codehaus.groovy.runtime.InvokerHelper
import org.openapitools.codegen.*
import org.openapitools.codegen.languages.*

@EqualsAndHashCode
class MyCodegenModel extends CodegenModel {

  boolean random = false
  boolean isRandom() { return this.random }

  String interfacesUnion = null

  static MyCodegenModel copy(CodegenModel source) {
    def target = new MyCodegenModel()

    InvokerHelper.setProperties(target, source.properties)

    return target
  }

}

class ModelProcessor {

  private static Map<String, Object> extractModelHash(Map<String, Object> objs, String modelName) {
    return objs?.get(modelName)?.get("models")?.get(0)
  }

  private static CodegenModel extractModel(Map<String, Object> objs, String modelName) {
    return extractModelHash(objs, modelName)?.get("model")
  }

  private static void setModel(Map<String, Object> objs, String modelName, CodegenModel model) {
    extractModelHash(objs, modelName).put("model", model)
  }

  static void replaceModel(Map<String, Object> objs, String modelName, @ClosureParams(value=FromString, options=["MyCodegenModel"]) Closure block) {
    def originalModel = extractModel(objs, modelName)
    def targetModel = MyCodegenModel.copy(originalModel)

    block(targetModel)

    setModel(objs, modelName, targetModel)
  }

}

class MyCodegen extends TypeScriptNodeClientCodegen {

  static main(String[] args) {
    OpenAPIGenerator.main(args)
  }

  MyCodegen() {
    super()
  }

  String name = "my-codegen"

  @Override
  Map<String, Object> postProcessAllModels(Map<String, Object> objs) {
    objs = super.postProcessAllModels(objs)

    ModelProcessor.replaceModel(objs, "Random") {
      it.random = true
      it.interfacesUnion = it.interfaces.join(" | ")
    }

    return objs
  }
}

MyCodegen.main(args)

postProcessAllModels と言うメソッドの挙動を書き換えて、独自のプロパティクラスへ差し替えることでテンプレート中で使える変数を増やして無理やり分岐できるようにしています。

model.mustache
{{>licenseInfo}}
{{#models}}
{{#model}}
{{#tsImports}}
import { {{classname}} } from './{{filename}}';
{{/tsImports}}
{{^isRandom}}{{>model_generic}}{{/isRandom}}{{#isRandom}}{{>model_random}}{{/isRandom}}
{{/model}}
{{/models}}
model_random.mustache
{{#description}}
/**
* {{{description}}}
*/
{{/description}}
export type {{classname}} = {{interfacesUnion}}

これ以外にも、もともと model.mustache だったものを model_generic.mustache にしていくらか行を削除しています。

どう考えても筋が良くないのですが、このようにして使える変数を増やさないと完全に別物のファイルを生成するのは難しいです。あるモデルに特化したテンプレートを指定できる仕組みが本体に入ると良さそうですが、提案できるほど考えられていないと言うのが現状です。

まとめ

API 定義を複雑にすると、コードの生成周りが非常に厄介になると言うのがこの記事の教訓です。記事の中では言及していませんが、 openapi-generator の内部構造はなかなかわかりにくく、 IntelliJ などの IDE を使い、デバッガーを起動できないとカスタマイズは不可能なように思われます。生半可な覚悟でカスタマイズをしようとするのは避けた方が良いでしょう。


  1. 条件分岐するためのデータを埋め込んで ランタイム での分岐は可能です。 

  2. https://github.com/OpenAPITools/openapi-generator/pull/1360 この PR により、基本的な部分はテンプレートで処理できるようになっています。 しかし、任意の oneOf を表現しようと思うと言語レベルでそういったものが用意されていないとなかなか難しいでしょう。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away