この記事はオプトテクノロジーズアドベントカレンダー(2018) 9 日目のエントリーです。
前書き
ウェブアプリケーションを作ると API とその仕様のドキュメントを用意することが多いと思います。最近は API の仕様をある規格にしたがって書き下し、それを元にクライアントを自動生成するような仕組みも広まってきています。
この記事では、そのような規格の一つである Open API を使ってクライアントを自動生成し、その出力結果をカスタマイズする方法について紹介します。
OpenAPI とは
正確なところは下記のリンク先を参照していただくとして、大まかなところでいうと REST API の入出力を記述するための仕様です。
元々は Swagger という名前でしたが、現在は OpenAPI という名前になっています。その流れで、関連ツールは swagger-*
という命名のものも多いです。
openapi-generator
OpenAPI Document から API のクライアントやスタブ実装を生成するツールでメジャーなものとして swagger-codegen
と openapi-generator
の 2 つがあります。
ここでは後者の openapi-generator
のカスタマイズ方法について記載していきます。
カスタマイズ
さて、いよいよ本題です。
基本はQuramy さんの記事を参考にして書いています。
swagger-codegen の話ですが現時点ではそこまで乖離もないので眺めてもらうと generator カスタマイズの基本がわかると思うのでまずはご一読ください。
そうです。 openapi-generator では mustache を採用しているので条件分岐などはできないのです1。そんなに多くはないですが稀に条件分岐したいこともあるので今回はそんなケースに立ち向かっていきます。
まずは自前の Generator を作ってみる
イメージを掴むためほぼ継承元の挙動を変更しないようにして自前の Generator を作ってみます。
@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 には特定のモデルだけ別のテンプレートを使うなどといった機能は用意されていないので、かなり大胆なことをする必要があります。
@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
と言うメソッドの挙動を書き換えて、独自のプロパティクラスへ差し替えることでテンプレート中で使える変数を増やして無理やり分岐できるようにしています。
{{>licenseInfo}}
{{#models}}
{{#model}}
{{#tsImports}}
import { {{classname}} } from './{{filename}}';
{{/tsImports}}
{{^isRandom}}{{>model_generic}}{{/isRandom}}{{#isRandom}}{{>model_random}}{{/isRandom}}
{{/model}}
{{/models}}
{{#description}}
/**
* {{{description}}}
*/
{{/description}}
export type {{classname}} = {{interfacesUnion}}
これ以外にも、もともと model.mustache
だったものを model_generic.mustache
にしていくらか行を削除しています。
どう考えても筋が良くないのですが、このようにして使える変数を増やさないと完全に別物のファイルを生成するのは難しいです。あるモデルに特化したテンプレートを指定できる仕組みが本体に入ると良さそうですが、提案できるほど考えられていないと言うのが現状です。
まとめ
API 定義を複雑にすると、コードの生成周りが非常に厄介になると言うのがこの記事の教訓です。記事の中では言及していませんが、 openapi-generator の内部構造はなかなかわかりにくく、 IntelliJ などの IDE を使い、デバッガーを起動できないとカスタマイズは不可能なように思われます。生半可な覚悟でカスタマイズをしようとするのは避けた方が良いでしょう。
-
条件分岐するためのデータを埋め込んで ランタイム での分岐は可能です。 ↩
-
https://github.com/OpenAPITools/openapi-generator/pull/1360 この PR により、基本的な部分はテンプレートで処理できるようになっています。 しかし、任意の oneOf を表現しようと思うと言語レベルでそういったものが用意されていないとなかなか難しいでしょう。 ↩