これは TECHSCORE Advent Calendar 2019 の17日目の記事です。
OpenAPI Generator のコード生成について
OpenAPI Generator は OpenAPI Specification の定義ファイルがあれば、API クライアントやサーバのスタブのコードを自動生成してくれるという便利な代物です。
ただ、生成されたコードがそのまま使えるとは言えません。例えば、OAuth2 で保護されている API にアクセスする場合、アクセストークンの発行が必要ですが、OpenAPI Generator のクライアントコード生成ではアクセストークン発行のコードは生成してくれません。
Java のクライアントコードを生成した場合、README に以下のようなサンプルコードが出力されますが、 setAccessToken してね、ということくらいしか書かれておらず、アクセストークンの発行の部分には触れられていません。
(Java のクライアントコード生成では、デフォルトで OkHttp 3 を利用したコードが生成されます。OkHttp 3 のクライアントコードの場合、一応アクセストークン発行のコードは生成されますが、Apache Oltu という既にプロジェクト終了したライブラリを使ったコードなので、あまり実案件では使いたくありません)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public static void main(String[] args) { ApiClient defaultClient = Configuration.getDefaultApiClient(); defaultClient.setBasePath("https://mail.paas.crmstyle.com/e"); // Configure OAuth2 access token for authorization: mail_oauth OAuth mail_oauth = (OAuth) defaultClient.getAuthentication("mail_oauth"); mail_oauth.setAccessToken("YOUR ACCESS TOKEN"); DefaultApi apiInstance = new DefaultApi(defaultClient); String user = "user_example"; // String | user name DeliverySettingRequest deliverySettingRequest = new DeliverySettingRequest(); // DeliverySettingRequest | try { DeliveryResponse result = apiInstance.createMailDeliverySetting(user, deliverySettingRequest); System.out.println(result); } catch (ApiException e) { System.err.println("Exception when calling DefaultApi#createMailDeliverySetting"); System.err.println("Status code: " + e.getCode()); System.err.println("Reason: " + e.getResponseBody()); System.err.println("Response headers: " + e.getResponseHeaders()); e.printStackTrace(); } } |
また、Spring WebFlux をライブラリとしてクライアントコード生成も可能ですが、これに関してはアクセストークン発行はおろか、そのままビルドも通らないという状況です。
今回は、 OpenAPI Generator で Spring WebFlux のクライアントコードを OAuth2 のアクセストークン発行付きで生成するところまでを試してみました。
実行環境は以下の通りです。OpenAPI Generator のバージョンは、現時点の最新安定版の 4.2.2 を使いました。
1 2 3 4 |
$ java -version java version "1.8.0_144" Java(TM) SE Runtime Environment (build 1.8.0_144-b01) Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode) |
サンプルコードはこちらにあります。
OpenAPI の定義ファイルは、Synergy! メールAPI をベースにしたものを使っています。
Groovy を使う
OpenAPI Generator が生成するコードを変更したい場合、普通なら Github リポジトリ からソースコードを落としてきて、Java のコードを修正して Maven でビルドして..という感じになると思いますが、 Clone する手間や Maven をインストールしたりちょっと面倒です。
Groovy を使うことで、必要なクラスは @Grab を指定することで実行時に解決でき、Maven は不要になるので、Groovy を使います。私は普段 Mac で開発しているため、Homebrew で Groovy をインストールしました。
手始めとして、JavaClientCodegen を継承したクラスを実行する Groovy スクリプトを作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Grab(group = 'org.openapitools', module = 'openapi-generator-cli', version = '4.2.2') import org.openapitools.codegen.* import org.openapitools.codegen.languages.* class TechscoreJavaClientCodegen extends JavaClientCodegen { static main(String[] args) { OpenAPIGenerator.main(args) } TechscoreJavaClientCodegen() { super() } String name = "techscore-codegen" } |
上記をファイル名 techscore-client-codegen.groovy として保存後、以下のコマンド実行で、output フォルダに Spring WebFlux のクライアントコードが生成されます。
1 2 3 4 5 6 7 |
groovy \ ./techscore-client-codegen.groovy \ generate \ -i ./openapi.yml \ -g TechscoreJavaClientCodegen \ -o ./output \ --library webclient |
テンプレートのカスタマイズ
上記で生成されるクライアントコードは、前述したとおりそのままではビルドが通らない、アクセストークン発行のコードが生成されないなどといった状態です。
生成されるファイルを修正・追加する
ビルドが通らないのは、生成される build.gradle に Spring 関係の依存関係について一切記載が無いことが原因です。
これを解消するために、OpenAPI Generator はテンプレートからコード生成するため、OpenAPI Generator のテンプレートをカスタマイズする必要があります。
Java のクライアントコードのテンプレートは、こちら から取得できます。
これをコピーして、ファイルを修正・追加することで生成されるコードを変更することができます。
以下のようにして、template ディレクトリ配下にコピーします。
1 2 3 4 5 6 7 8 9 10 |
mkdir -p {repodir,template} && cd repodir git init git remote add origin https://github.com/OpenAPITools/openapi-generator.git git config core.sparsecheckout true vi .git/info/sparse-checkout ← modules/openapi-generator/src/main/resources/Java/ を追記 git fetch origin git checkout v4.2.2 git pull origin v4.2.2 cp -pR modules/openapi-generator/src/main/resources/Java/* ../template/ cd ../ && rm -Rf repodir |
template ディレクトリの中は以下のような構成になっています。拡張子 .mustcache は OpenAPI Generator が利用しているテンプレートエンジン Mustache のテンプレートファイルです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
$ tree -L 3 template template ├── auth │ ├── ApiKeyAuth.mustache │ ├── Authentication.mustache │ ├── HttpBasicAuth.mustache │ ├── HttpBearerAuth.mustache │ ├── OAuth.mustache │ └── OAuthFlow.mustache ├── libraries │ ├── feign │ (略) │ └── webclient │ ├── auth │ ├── ApiClient.mustache │ ├── README.mustache │ ├── api.mustache │ ├── api_test.mustache │ └── pom.mustache ├── ApiClient.mustache (略) ├── build.gradle.mustache ├── build.sbt.mustache (略) └── xmlAnnotation.mustache |
クライアントコード生成時、--library webclient
(Spring WebFluxのコード生成)を指定すると、 libraries/webclient のテンプレートが使われることになります。
今回の場合、 libraries/webclient/build.gradle.mustache を作成すれば、build.gradle を変更できます。
build.gradle.mustache の内容はこちらを参照ください。
以下のように -t オプションを追加してテンプレート指定することでテンプレートの差し替えが可能です。
1 2 3 4 5 6 7 8 |
groovy \ ./techscore-client-codegen.groovy \ generate \ -i ./openapi.yml \ -g TechscoreJavaClientCodegen \ -o ./output \ -t ./template \ --library webclient |
アクセストークン発行のコードを生成するためには、新しくテンプレートファイルを追加する必要があります。
今回は、Spring WebFlux のクライアントコードのため、WebClient を DI できるように、テンプレートを作成し libraries/webclient/WebClientConfig.mustache に登録しました。
Synergy! メールAPI ではアクセストークン発行時に scope と audience をリクエストボディに含める必要があるため、そのような実装をしています。また、アクセストークンはある程度キャッシュを利用するようにしています。実装内容はリンク先を参照いただければと思います。
また、ApiClient.mustache, api.mustache も、上記 WebClient を DI できるように少しずつ内容を変更しています。こちらも内容はリンク先を参照ください。
ただし、新しいテンプレートを追加しても、それだけではテンプレートを読み取ってくれません。Groovy に以下の Override メソッドを追加して、テンプレートを読み取るようにします。
1 2 3 4 5 6 7 8 9 10 11 |
@Override public void processOpts() { final String invokerFolder = (sourceFolder + '/' + invokerPackage).replace(".", "/"); final String apiFolder = (sourceFolder + '/' + apiPackage).replace(".", "/"); super.processOpts() if (WEBCLIENT.equals(getLibrary())) { // add WebClientConfig supportingFiles.add(new SupportingFile("WebClientConfig.mustache", invokerFolder, "WebClientConfig.java")) } } |
Spring Boot の設定ファイル application.yml を追加する
先ほど触れませんでしたが、上記の WebClientConfig.mustache を元に生成される WebClientConfig.java は、Spring Boot の定義ファイルである application.yml から OAuth2 関連の設定内容を読み取ることを前提としています。(この辺りや、この辺りです。)
application.yml もテンプレートファイルが存在しないため、先ほど同様テンプレートファイルを作成して、それを読み取るコードを Groovy スクリプトに追記する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
{{#authMethods}}{{#isOAuth}} spring: security.oauth2.client: provider: paas: token-uri: https://auth.paas.crmstyle.com/oauth2/token registration: paas: authorization-grant-type: client_credentials client-id: your-client-id client-secret: your-client-secret scope: - mail:send - mail:result audience: https://mail.paas.crmstyle.com{{/isOAuth}}{{/authMethods}} logging: level: reactor.netty: DEBUG |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Override public void processOpts() { final String invokerFolder = (sourceFolder + '/' + invokerPackage).replace(".", "/"); final String apiFolder = (sourceFolder + '/' + apiPackage).replace(".", "/"); super.processOpts() if (WEBCLIENT.equals(getLibrary())) { // add WebClientConfig supportingFiles.add(new SupportingFile("WebClientConfig.mustache", invokerFolder, "WebClientConfig.java")) // add application.yml ★追記 supportingFiles.add(new SupportingFile("application.yml.mustache", projectFolder + '/resources', "application.yml")) } } |
SecuritySchemes を読み取って置換する
先ほどの application.yml のテンプレートは、アクセストークン発行 URL やスコープ、audience を固定で記載していました。(以下の★マークをつけている部分です。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
{{#authMethods}}{{#isOAuth}} spring: security.oauth2.client: provider: paas: token-uri: https://auth.paas.crmstyle.com/oauth2/token ★ここ registration: paas: authorization-grant-type: client_credentials client-id: your-client-id client-secret: your-client-secret scope: ★ここ - mail:send - mail:result audience: https://mail.paas.crmstyle.com ★ここ{{/isOAuth}}{{/authMethods}} logging: level: reactor.netty: DEBUG |
どうせなら、OpenAPI の定義ファイルの SecuritySchemes から読み取った内容で置換したいです。
1 2 3 4 5 6 7 8 9 10 11 |
securitySchemes: mail_oauth: type: oauth2 description: mail auth flows: clientCredentials: tokenUrl: "https://auth.paas.crmstyle.com/oauth2/token" ★これに動的に差し替えたい scopes: ★これに動的に差し替えたい mail:send: "delivery" mail:result: "get the delivery results" x-audience: "https://mail.paas.crmstyle.com" ★これに動的に差し替えたい |
アクセストークン発行 URL やスコープは、デフォルトの状態で OpenAPI 定義ファイルから内容を読み取ってくれます。
application.yml のテンプレートを以下のように変更することで、差し替えが可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{{#authMethods}}{{#isOAuth}} spring: security.oauth2.client: provider: paas: token-uri: {{tokenUrl}} ★これで差し替え可能 registration: paas: authorization-grant-type: client_credentials client-id: your-client-id client-secret: your-client-secret scope: ★これで差し替え可能 {{#scopes}} - "{{scope}}" {{/scopes}} audience: https://mail.paas.crmstyle.com ★まだ置換できない {{/isOAuth}}{{/authMethods}} logging: level: reactor.netty: DEBUG |
audience に関しては、 OpenAPI の標準仕様には含まれていない内容となり、Specification Extensions として定義する必要があるため、デフォルトでは OpenAPI Generator では読み取ってくれません。
OpenAPI Generator での SecuritySchemes の読み取りは、DefaultCodegen#fromSecurity が行なっています。
DefaultCodegen は、 JavaClientCodegen の一番祖先の継承元です。
内容を見ると、SecuritySchemes から読み取った内容を、CodegenSecurity というオブジェクトに詰めていっているように見えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
/** * Convert map of OAS SecurityScheme objects to a list of Codegen Security objects * * @param securitySchemeMap a map of OAS SecuritySchemeDefinition object * @return a list of Codegen Security objects */ @SuppressWarnings("static-method") public List fromSecurity(Map<String, SecurityScheme> securitySchemeMap) { if (securitySchemeMap == null) { return Collections.emptyList(); } List codegenSecurities = new ArrayList(securitySchemeMap.size()); for (String key : securitySchemeMap.keySet()) { final SecurityScheme securityScheme = securitySchemeMap.get(key); CodegenSecurity cs = CodegenModelFactory.newInstance(CodegenModelType.SECURITY); cs.name = key; cs.type = securityScheme.getType().toString(); cs.isCode = cs.isPassword = cs.isApplication = cs.isImplicit = false; cs.isBasicBasic = cs.isBasicBearer = false; cs.scheme = securityScheme.getScheme(); if (SecurityScheme.Type.APIKEY.equals(securityScheme.getType())) { cs.isBasic = cs.isOAuth = false; cs.isApiKey = true; cs.keyParamName = securityScheme.getName(); cs.isKeyInHeader = securityScheme.getIn() == SecurityScheme.In.HEADER; cs.isKeyInQuery = securityScheme.getIn() == SecurityScheme.In.QUERY; cs.isKeyInCookie = securityScheme.getIn() == SecurityScheme.In.COOKIE; //it assumes a validation step prior to generation. (cookie-auth supported from OpenAPI 3.0.0) } else if (SecurityScheme.Type.HTTP.equals(securityScheme.getType())) { cs.isKeyInHeader = cs.isKeyInQuery = cs.isKeyInCookie = cs.isApiKey = cs.isOAuth = false; cs.isBasic = true; if ("basic".equals(securityScheme.getScheme())) { cs.isBasicBasic = true; } else if ("bearer".equals(securityScheme.getScheme())) { cs.isBasicBearer = true; cs.bearerFormat = securityScheme.getBearerFormat(); } } else if (SecurityScheme.Type.OAUTH2.equals(securityScheme.getType())) { cs.isKeyInHeader = cs.isKeyInQuery = cs.isKeyInCookie = cs.isApiKey = cs.isBasic = false; cs.isOAuth = true; final OAuthFlows flows = securityScheme.getFlows(); if (securityScheme.getFlows() == null) { throw new RuntimeException("missing oauth flow in " + cs.name); } if (flows.getPassword() != null) { setOauth2Info(cs, flows.getPassword()); cs.isPassword = true; cs.flow = "password"; } else if (flows.getImplicit() != null) { setOauth2Info(cs, flows.getImplicit()); cs.isImplicit = true; cs.flow = "implicit"; } else if (flows.getClientCredentials() != null) { setOauth2Info(cs, flows.getClientCredentials()); cs.isApplication = true; cs.flow = "application"; } else if (flows.getAuthorizationCode() != null) { setOauth2Info(cs, flows.getAuthorizationCode()); cs.isCode = true; cs.flow = "accessCode"; } else { throw new RuntimeException("Could not identify any oauth2 flow in " + cs.name); } } codegenSecurities.add(cs); } } |
fromSecurity からさらに、 DefaultCodegen#setOAuth2Info という private メソッドが呼ばれています。
実装を見ると、OAuth Flow Object の内容を、CodegenSecurity に詰めているようです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
private void setOauth2Info(CodegenSecurity codegenSecurity, OAuthFlow flow) { codegenSecurity.authorizationUrl = flow.getAuthorizationUrl(); codegenSecurity.tokenUrl = flow.getTokenUrl(); if (flow.getScopes() != null && !flow.getScopes().isEmpty()) { List<Map<String, Object>> scopes = new ArrayList<Map<String, Object>>(); int count = 0, numScopes = flow.getScopes().size(); for (Map.Entry<String, String> scopeEntry : flow.getScopes().entrySet()) { Map<String, Object> scope = new HashMap<String, Object>(); scope.put("scope", scopeEntry.getKey()); scope.put("description", escapeText(scopeEntry.getValue())); count += 1; if (count < numScopes) { scope.put("hasMore", "true"); } else { scope.put("hasMore", null); } scopes.add(scope); } codegenSecurity.scopes = scopes; } } |
さらに読み進めると、OAuthFlow に getExtensions というメソッドがあり、
CodegenSecurity に、 vendorExtensions というプロパティが用意されていることが分かります。
Specification Extensions の内容を OAuthFlow#getExtensionsから読み取って、 CodegenSecurity の vendorExtensions にセットすれば、テンプレートファイルの置換が可能となるのではと推測できます。
実際に Groovy スクリプトを以下のように変更してみて試してみます。setOAuth2Info は private スコープなので、内容を DefaultCodegen からそのまま持ってきて getExtension するコードを追記しています。
fromSecurity が setOAuth2Info を呼ぶ作りになっているため、fromSecurity も内容は変更せずオーバーライドしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
@Grab(group = 'org.openapitools', module = 'openapi-generator-cli', version = '4.2.2') import org.openapitools.codegen.* import org.openapitools.codegen.languages.* import io.swagger.v3.oas.models.security.*; class TechscoreJavaClientCodegen extends JavaClientCodegen { static main(String[] args) { OpenAPIGenerator.main(args) } TechscoreJavaClientCodegen() { super() } String name = "techscore-codegen" @Override public void processOpts() { final String invokerFolder = (sourceFolder + '/' + invokerPackage).replace(".", "/"); final String apiFolder = (sourceFolder + '/' + apiPackage).replace(".", "/"); super.processOpts() if (WEBCLIENT.equals(getLibrary())) { // add WebClientConfig supportingFiles.add(new SupportingFile("WebClientConfig.mustache", invokerFolder, "WebClientConfig.java")) // add application.yml supportingFiles.add(new SupportingFile("application.yml.mustache", projectFolder + '/resources', "application.yml")) } } @Override @SuppressWarnings("static-method") public List fromSecurity(Map<String, SecurityScheme> securitySchemeMap) { if (securitySchemeMap == null) { return Collections.emptyList(); } (略) return codegenSecurities; } private void setOauth2Info(CodegenSecurity codegenSecurity, OAuthFlow flow) { codegenSecurity.authorizationUrl = flow.getAuthorizationUrl(); codegenSecurity.tokenUrl = flow.getTokenUrl(); if (flow.getScopes() != null && !flow.getScopes().isEmpty()) { List<Map<String, Object>> scopes = new ArrayList<Map<String, Object>>(); int count = 0, numScopes = flow.getScopes().size(); for (Map.Entry<String, String> scopeEntry : flow.getScopes().entrySet()) { Map<String, Object> scope = new HashMap<String, Object>(); scope.put("scope", scopeEntry.getKey()); scope.put("description", escapeText(scopeEntry.getValue())); count += 1; if (count < numScopes) { scope.put("hasMore", "true"); } else { scope.put("hasMore", null); } scopes.add(scope); } codegenSecurity.scopes = scopes; } // Specification Extensions をセット if (flow.getExtensions() != null) { Map<String, Object> extensions = flow.getExtensions(); codegenSecurity.vendorExtensions = extensions; } } } |
次に application.yml のテンプレートファイルを以下のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
{{#authMethods}}{{#isOAuth}} spring: security.oauth2.client: provider: paas: token-uri: {{tokenUrl}} registration: paas: authorization-grant-type: client_credentials client-id: your-client-id client-secret: your-client-secret scope: {{#scopes}} - "{{scope}}" {{/scopes}} {{#vendorExtensions}} ★ 変更 audience: {{x-audience}}{{/vendorExtensions}}{{/isOAuth}}{{/authMethods}} logging: level: reactor.netty: DEBUG |
この状態で Groovy を実行すると、SecuritySchemes から内容を読み取った src/main/resources/application.yml を得ることができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
spring: security.oauth2.client: provider: paas: token-uri: https://auth.paas.crmstyle.com/oauth2/token registration: paas: authorization-grant-type: client_credentials client-id: your-client-id client-secret: your-client-secret scope: - "mail:send" - "mail:result" audience: https://mail.paas.crmstyle.com logging: level: reactor.netty: DEBUG |
終わりに
今回は Java + Spring WebFlux の例で OpenAPI Generator のクライアントコード生成のカスタマイズについて説明しました。ClientCodegen を継承したクラスを作り、テンプレートを修正・追記することで、他のサポートされている言語のクライアントコードも自分の好きなように変更することが可能と思います。(OpenAPI Generator のコードは読み解く必要がありますが)
皆さんも是非一度試してみてはいかがでしょうか。