はじめに
こんにちは。マイグレーションブロックの藤本です。
ZOZOのマイクロサービスの開発では、以前の「OpenAPI3を使ってみよう!Go言語でクライアントとスタブの自動生成まで!」や「Go言語におけるOpenAPIを使ったレスポンス検証」の記事にもあるように、OpenAPI(Swagger)を使ってAPIの仕様を管理しています。そして私たちのチームでは、OpenAPIのYAMLからControllerのInterfaceとレスポンスオブジェクトのコードを生成して、それを実装することでAPIの開発を進めています。
この記事では、OpenAPI Generatorを使ったOpenAPI定義からのコード生成と、Spring Framework(以下、Spring)のカスタムデータバインディング1を共存させるために実施したことをご紹介します。
先に結論から
今回はテンプレートを編集する方法で実現させました。概要は次のとおりです。
- OpenAPI Generatorのテンプレートをエクスポート
- エクスポートしたテンプレートに独自の設定を追加できるようにする
- Springのカスタムデータバインディングを設定する
また今回使用した主な言語、フレームワーク、ライブラリのバージョンは次のとおりです。
Java 11 https://adoptium.net/?variant=openjdk11&jvmVariant=hotspot Spring Boot 2.6.4 https://github.com/spring-projects/spring-boot springdoc-openapi v1.6.6 https://github.com/springdoc/springdoc-openapi OpenAPI Generator 5.4.0 https://github.com/OpenAPITools/openapi-generator
それでは手順を説明していきます。
OpenAPIの定義からコードを生成する
サンプルAPIの定義
まずはYAMLでAPIを定義します。サンプルとして用意したOpenAPIの定義は次のとおりです。Userのid
をパラメーターとして受け取って、Userオブジェクトとしてid
とname
を返すAPIになっています。
# openapi.yaml openapi: 3.0.3 info: title: Sample API description: Sample API version: 1.0.0 contact: name: Sample email: sample@example.com servers: - url: http://localhost:8080 paths: '/v1/user': get: operationId: get-user summary: User API description: Userを取得します parameters: - in: query name: user_id description: ユーザーID required: true schema: type: integer format: int32 example: 123 tags: - User responses: '200': $ref: ./schemas/user-response.yaml '400': description: 400 (Bad Request) headers: http_status: description: HTTPステータス schema: type: integer
# schemas/user-response.yaml description: 200 (OK) content: application/json: schema: $ref: ./user.yaml headers: http_status: description: HTTPステータス schema: type: integer
# schemas/user.yaml type: object properties: id: type: integer format: int32 example: 123 name: type: string example: name
コードの生成
準備したOpenAPIの定義からコードを生成します。私たちのチームではプラグイン等を使わず、Download JARの手順に従ってJARファイルを取得して、javaコマンドで実行する方法を採用しています。表題のとおりSpringでAPIを開発しているので、g
オプション(--generator-name
)でspring
を指定しています。
java -jar openapi-generator-cli.jar generate \ -i ./openapi.yaml \ -g spring \ -o generated \ -c ./openapi.config \ --group-id com.example \ --artifact-id sample-api-generated \ --artifact-version 0.0.1-SNAPSHOT \ --api-package com.example.api.controller \ --model-package com.example.api.model
先ほどのOpenAPIの定義から生成したコードの抜粋は次のとおりです。Interfaceとレスポンス用のUserクラスが生成されています。
// Interface public interface UserApi { // ...省略... /** * GET /v1/user : User API * Userを取得します * * @param userId ユーザーID (required) * @return 200 (OK) (status code 200) * or 400 (Bad Request) (status code 400) */ @Operation( operationId = "getUser", summary = "User API", tags = {"User"}, responses = { @ApiResponse( responseCode = "200", description = "200 (OK)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = User.class)) ), @ApiResponse(responseCode = "400", description = "400 (Bad Request)") } ) @RequestMapping( method = RequestMethod.GET, value = "/v1/user", produces = {"application/json"} ) default ResponseEntity<User> getUser( @NotNull @Parameter(name = "user_id", description = "ユーザーID", required = true, schema = @Schema(description = "")) @Valid @RequestParam(value = "user_id", required = true) Integer userId ) { // ...省略... } }
// response public class User implements Serializable { private static final long serialVersionUID = 1L; @JsonProperty("id") private Integer id; @JsonProperty("name") private String name; // ...省略...
Interfaceを実装したControllerは次のとおりです。
@RestController public class UserApiController implements UserApi { @Override public ResponseEntity<User> getUser(Integer userId) { // TODO 仮実装 User user = new User() .id(-1) .name("dummy"); return new ResponseEntity<>( user, HttpStatus.OK ); } }
引数に追加したい
先ほどのControllerでUserというオブジェクトはユーザーを表しているのですが、おそらく他のエンドポイントでも欲しくなります。必要だからといってそれぞれのエンドポイントに同じような取得処理を書くのは、設計の観点からも保守の観点からもよくありません。このユーザーのように、「Controllerに処理が移った時に欲しいもの」をSpringで渡せるようにするには、Controllerの引数として定義が必要です。メソッドに引数を追加した例は次のとおりです。
public ResponseEntity<User> getUser(Integer userId, User user) { // ...省略...
ただし、Controllerに引数を追加するためにはInterfaceにも追加されている必要があり、Interfaceに追加するためにはYAMLに定義が必要です。
クライアントから受け取らないものは隠しておきたい
YAMLに定義してコードを生成するのは簡単ですが、クライアントから受け取るパラメーターとして定義することになるので、本来はクライアントから受け取るつもりの無いものが外から見える状態になります。「引数として定義したい」と「クライアントから受け取らないものは隠しておきたい」という2つの要件が衝突するという問題が発生してしまいます。
どのようにして解決するか
今回はOpenAPI Genratorのテンプレートを編集することで、この問題の解決を目指しました。
テンプレートのエクスポート
編集するにはテンプレートの準備が必要です。OpenAPI Generatorはテンプレートをエクスポートする機能があるので、デフォルトのテンプレートが欲しいときはこれを使います。コード生成の時と同様に、g
オプションでSpring用のテンプレートを指定しています。テンプレートは非常に多くのファイルで構成されているので、テンプレート置き場としてディレクトリを用意することをおすすめします。
java -jar openapi-generator-cli.jar author template -g spring -o templates
パラメーターの準備
次に、エクスポートしたテンプレートを編集していきます。OpenAPI Generatorには独自に定義した値をテンプレートにわたす仕組みが備わっています。この仕組みを使って、内部からはSpringが参照できるが、外部には公開していない状態を表現できるようにします。
今回は他のパラメーターと区別するため、Cookieで受け取るパラメーターかのように定義しました。UserオブジェクトをControllerの引数に追加したいので、user-param.yaml
としています。ここで独自の設定としてx-hidden-parameter
を追加しておきます。隠しておきたいパラメーターなので値はtrue
です。
# schemas/user-param.yaml in: cookie name: user description: ユーザー情報 required: false x-hidden-parameter: true schema: $ref: ./schemas/user.yaml
テンプレートの編集
パラメーターの準備ができたら、テンプレートに制御を追加します。Cookieのパラメーターとして定義したので、対象のファイルはtemplates/cookieParams.mustache
になります。わかりやすさのために改行とインデントを追加していますが、元のファイルは1行で書かれています。
{{#isCookieParam}} {{#useBeanValidation}} {{>beanValidationQueryParams}} {{/useBeanValidation}} {{>paramDoc}} @CookieValue("{{baseName}}"){{>dateTimeParam}} {{>optionalDataType}} {{paramName}} {{/isCookieParam}}
先ほどのuser-param.yaml
に追加した設定値をCookie用のテンプレートで読み込みます。読み込むときはvendorExtensions
に続けてドットとプロパティ名を記述します。今回追加した設定ではvendorExtensions.x-hidden-parameter
になります。
編集後のテンプレートは次のとおりです。先ほどと同様に改行とインデントを入れています。
{{#isCookieParam}} {{^vendorExtensions.x-x-hidden-parameter}} {{#useBeanValidation}} {{>beanValidationQueryParams}} {{/useBeanValidation}} {{>paramDoc}} @CookieValue("{{baseName}}") {{/vendorExtensions.x-x-hidden-parameter}} {{#vendorExtensions.x-x-hidden-parameter}} @Parameter(hidden = true) {{/vendorExtensions.x-x-hidden-parameter}} {{>dateTimeParam}} {{>optionalDataType}} {{paramName}} {{/isCookieParam}}
コードを再生成
テンプレートの編集が終わったらコードを再生成します。このときテンプレートが置いてあるディレクトリをt
オプション(--template-dir
)で指定します。再生成したInterfaceの引数にUserオブジェクトが追加されるので、これを実装していたControllerも修正します。
// Interfaceのメソッド定義だけ抜粋 default ResponseEntity<User> getUser( @NotNull @Parameter(name = "user_id", description = "ユーザーID", required = true, schema = @Schema(description = "")) @Valid @RequestParam(value = "user_id", required = true) Integer userId, @Parameter(hidden = true) User user ) {}
カスタムデータバインディングの設定
ここまでくればあとはカスタムデータバインディングを設定するだけです。まずはHandlerMethodArgumentResolver
の実装クラスを作って、必要な処理を実装します。supportsParameter
メソッドで適用する条件を指定して、resolveArgument
メソッドで実際に取得したい値を生成します。
実装例は次のとおりです。
//UserArgumentResolver.java public class UserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return User.class.isAssignableFrom(parameter.getParameterType()); } @Override public Object resolveArgument( MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) throws Exception { var httpServletRequest = webRequest.getNativeRequest(HttpServletRequest.class); if (httpServletRequest == null) { return null; } var userId = httpServletRequest.getParameter("user_id"); if (!StringUtils.hasLength(userId)) { return null; } // 例外処理などは省略 var id = Integer.parseInt(userId); return new User().id(id).name("resolved user"); } }そして、このクラスをSpringが管理している`HandlerMethodArgumentResolver`のListに追加すると、ControllerクラスでUserオブジェクトを取得できるようになります。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new UserArgumentResolver()); } }## 実際に動かした結果 以上の内容を実装してSpring Bootアプリとして動かすと、Userオブジェクトのidとnameに、指定した値と`resolved user`が設定されていることを確認できます。 ![curlコマンドによるAPIの実行結果](https://cdn-ak.f.st-hatena.com/images/fotolife/v/vasilyjp/20220311/20220311091412.jpg) Swagger UIで確認しても、ちゃんとUserオブジェクトは外から見えなくなっています。 ![ブラウザによるswagger-uiの表示](https://cdn-ak.f.st-hatena.com/images/fotolife/v/vasilyjp/20220311/20220311091418.jpg) # まとめ やや強引な方法ではありますが、OpenAPI Generatorでコードを生成しながら、Springのカスタムデータバインディングの仕組みを使えました。これでコード生成の恩恵を受けつつ、共通の情報の取得を各エンドポイントで意識しなくてもよくなります。今回ご紹介した方法以外にも解決方法はあるはずなので、より良いやり方がないかは引き続き検討していきたいです。 # さいごに ZOZOでは一緒にサービスを成長させていく仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。
-
任意の型のオブジェクトをControllerに差し込む仕組みのことを
カスタムデータバインディング
と呼んでいます。↩