OpenAPI Generatorのコード生成とSpring Frameworkのカスタムデータバインディングを共存させる

header

はじめに

こんにちは。マイグレーションブロックの藤本です。

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オブジェクトとしてidnameを返す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が参照できるが、外部には公開していない状態を表現できるようにします。

openapi-generator.tech

今回は他のパラメーターと区別するため、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では一緒にサービスを成長させていく仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。

hrmos.co


  1. 任意の型のオブジェクトをControllerに差し込む仕組みのことをカスタムデータバインディングと呼んでいます。
カテゴリー