システム開発で思うところ

Javaで主にシステム開発をしながら思うところをツラツラを綴る。主に自分向けのメモ。EE関連の情報が少なく自分自身がそういう情報があったら良いなぁということで他の人の参考になれば幸い

PayaraでOpenAPI(MicroProfile)

はじめに

PayaraでOpenAPI-UIをつかって、WebAPIの仕様と実行インターフェースを準備する実装です。
軽く触ってみた感じの実装例や、Quarkus(MicroProfileの実装)を使ったものはあったのですが標準仕様だけの範疇だけでできるものはなかなか見つからなかったので色々と試しながらやってやってみました。

メインをOpenAPIにするのでJAX-RS(Jakarta RESTful Web Services)についての説明は割愛します。

実行環境

やってみて思ったこと・わかったこと

実際にやってみて思ったことなどを先に。
どうしてこうなっているの?というのが分かっていると以下のコードの理由が分かりやすいように思ったので。

OpenAPIの記述は実装よりも優先

実装からある程度自動生成はされますが、 OpenAPIの記述の方が優先されるのでOpenAPI-UIでつけるインターフェースの作り込みは自分でコツコツ書き込む方と割り切った方が良さそうです。
例えばEnumクラスはEnumの要素を enumeration で列挙するのを基本となるみたいです。ちょっと面倒ですね…。
ちなみに @BeanParamで定義したものは、そのままではどうやってもOpenAPI-UIで使えないのですが、OpenAPIの記述が実装よりも優先されることで作り込むことができます*1。

アノテーションで出来ないときはコードで作り込み

リストのクエリパラメータはアノテーションでは実現出来ませんでした。
そんなときは OASModelReader の実装の中で OASFactory.createComponents() をつかってコードで作り込むと実現できることもあります。
アノテーションで頑張って上手くいかないときは割り切ってコードを書くのも一案です。
ちなみに、上記の「Enumはenumeration の列挙」ですが、コードでスキーマを定義すれば共通化できます。

OpenAPIの記述と実行記述は分けておきたい

メソッドの引数に@Parameterを付ける方法もありますが、個人的にはメソッドアノテーションに寄せた方がコードが読みやすいかなと思います。
イメージとしてはRESTfulの実装をした上で、そのコード部分には手を加えずにメソッドアノテーションで情報を付与するという流れで書き足した方が好みです。
仮引数の中に複雑なアノテーションを記述していくと、コードの引数がどこにあるのか視認性が悪くなるというのが理由です。
ただ、あんまりそういう書き方を推奨するという情報も無かったので王道ではないかもしれないです。

tagをつかってコントローラー単位で集約

tagを使うのURLをまとめることが出来ます。
以前、SpringBoot & springdoc-openapi を使った時はtagを使わなかったので全部がフラットになってドキュメントとしての視認性が悪かったように思います。
なお、tagを複数付与するとtagの分だけ同じURLが同じOpenAPI-UIに表示されます。
OpenAPIの定義をDSLとしてさらに自動生成をすると、不具合に繋がるかもしれないので複数tagを付与するときは、その先も含めて事前に技術検証をしておくことをおすすめします。

レスポンスの型はGenericを使わないように細かく作成

@Schema(implementation = Hoge.class)

みたいに記述することでレスポンスの型の指定ができるのですがGenericが使えません。
なので、レスポンスクラスを代表として、その派生クラスをインナークラスとして定義しました。

public class UserResponse extends UserRequest {
 (略)
  static class UserResponseBody extends ResponseBody<UserResponse> {}
}

@ExampleObjectをちゃんと書く

Genericを使わなければ型情報からある程度はOpenAPI-UIで表示可能な定義を実装から自動生成してくれます。
ただ今回は ResponseBodyという共通定義にレスポンスをGenericで定義して入れ子構造な感じにしたので、そのあたりは自動ではできず*2。
リクエストは入れ子構造にしなかったのですが、Object型(例えばEnum)を使ったりすると、exampleが思ったように出力されなかったので、こちらもコツコツ定義しました。
レスポンスはとりあえず条件を指定して実際に出力をすればexampleが無くても最悪なんとかできなくはないのですが、リクエストはPOSTやPUTをするときにOpenAPI-UIで使いたいので手間がかかったとしても、きちんと記述することをお勧めします。

@ExampleObjectを記述するときの留意事項として

  • MediaTypeを揃える
  • nameがexampleのキーになるので記述を忘れない

というのがハマったところでした。

パラメータの記載は @Parameters を使う

引数ではなくメソッドにパラメータを指定する場合、パラメータが1つの場合でも、@Parametersを使わないとOpenAPIの出力として情報が出力されませんでした。

OpenAPI yamlでの出力から逆算して考える

例えば、ファイルアップロードの書き方についてアノテーションやコードでの定義方法を探してすぐに見つからない場合は、最終的なOpenAPIのyamlでの書き方を調べて、それをそれをどうやったらアノテーションもしくはコードで定義できるのか?というのを逆引きで組み立てるとスムーズに試行が進められました。
今回の実装でいうと

  • Enumを検索用クエリとしてリストにしたい
  • 複数ファイルのアップロードの設定のやり方

が、逆引きから見つけられた定義でした。

pom

OpenAPI-UIを使うためのライブラリ。
これがないとOpenAPI-UIのページが作られないです。

<dependency>
    <groupId>org.microprofile-ext.openapi-ext</groupId>
    <artifactId>openapi-ui</artifactId>
    <version>2.1.0</version>
</dependency>

そして

http://localhost:8080/ee10-01-openapi/api/openapi-ui/index.html

みたいな感じで、アプリのルートから「openapi-ui/index.html」へアクセスをするとOpenAPI-UIの画面が表示されます。

また、以下にアクセスをすると OpenAPIの定義(こちらはPayaraの実装が作成しているもの)を確認することができます。
なので、自分が意図した画面表示ができていないときは、こちらを確認して どういう定義になれば良いのか確認をしながら作業を進めることになります。

[http://localhost:8080/openapi]

Responseの構造体

まずはResponseの構造体から。
共通の構造体をラップしたものをレスポンスするようにしたいと思います。
フロントでHTTPステータスで判定をするのも良いですが、正常異常をif文で分けたいとか、そういう付加情報を設けたいケースを想定したものです。
もちろん、共通クラスをextendするやり方でも良いと思います。

@Schema
public class BaseResponseBody {
  @Schema(title = "レスポンス成否", description = "正常の場合はtrue", readOnly = true)
  boolean ok;
@Schema
public class ResponseBody<T> extends BaseResponseBody {

  @Schema(title = "レスポンスボディ", description = "レスポンス毎の独自の型のオブジェクト", readOnly = true)
  private T body;

(getter setter 他は省略)

リスト構造の場合はこちらを使います。

@Schema
public class ResponseListBody<T> extends BaseResponseBody {

  @Schema(title = "レスポンスボディ", description = "レスポンス毎の独自の型のオブジェクト", readOnly = true)
  private List<T> body;

  public ResponseListBody() {}

(getter setter 他は省略)

これを付与するためのFactoryを使ってResponseをつくるという流れです。
ちなみに GenericEntityを使っていますが、多分使わなくても結果は同じじゃないかな?と思います*3。

public static <T> Response success(T body) {
  var entity = new ResponseBody<T>(true, body);
  var responseBuilder = Response.ok(new GenericEntity<ResponseBody<T>>(entity) {});
  return responseBuilder.build();
}

GET

シンプルなパスアクセス

一番シンプルな記載はこんな感じです。
ここでポイントになるのはレスポンスの型の指定とその実装です。

static class UserResponseBody extends ResponseBody<UserResponse> {}

という感じで、OpenAPI用に型クラスを作って、それを指定します。
勿論、スキーマを個別に定義する方法もありますが、OASModelReader に記載する必要があるのと文字列での指定になるのでクラス記述で対応できるものは極力そちらで対応をしておくのが良いと思います。

@ExampleObject ですが、externalValue にクラスパスを指定しても、そこにあるJSONファイルを読み込んではくれませんでした*4。
なのでコツコツとベタで書き込むことになります*5。

  @GET
  @Path("{id}")
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(summary = "ユーザー情報を検索します")
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = UserResponse.UserResponseBody.class),
            examples = {
              @ExampleObject(
                  name = "default",
                  value =
                      "{\n"
                          + "  \"body\": {\n"
                          + "    \"gender\": \"OTHER\",\n"
                          + "    \"name\": \"Name:example\",\n"
                          + "    \"id\": \"100\"\n"
                          + "  },\n"
                          + "  \"ok\": true\n"
                          + "}")
            })
      },
      responseCode = "200")
  @Parameters({@Parameter(name = "id", description = "ユーザーID")})
  public Response getUserById(@PathParam("id") String id) {

レスポンスの定義もちゃんと出ていますね。

1点、解決が出来なかったことがあります。
リクエストとレスポンスを継承で実装したのですが、おそらくその影響でEnum部分の要素が重複して登録されてしまいました。
まぁ、、レスポンスは受け手なので実害は少ないということで、ここは目をつぶることにしました。

クエリを使った問い合わせ

クエリを使った場合は複数の結果を返却することもあるので配列が戻り値の型になります。
定義のコード実装はこんな感じです。

  static class UserResponseListBody extends BaseResponseBody {
    @SuppressFBWarnings("UUF_UNUSED_FIELD")
    private List<UserResponse> body;
  }

本来、ResponseListBodyのプロパティを記述しなくて良いのですが、OpenAPIで定義する型情報としては、プロパティを上書きするようなコードを書くことになります。

感覚的には以下なのですが、これだとGenericが List の入れ子になっていて出力されたOpenAPIの定義にクラス型のスキーマが設定されません*6。

static class UserResponseListNgBody extends ResponseListBody<UserResponse> {}

いずれにしても、このインナークラスは「OpenAPIのスキーマを出力するためのクラス」なので、こうやったらできるよ くらいの気持ちで割り切った方が良いと思います*7。

上記に加えて、複数の@ExampleObject を使う実装例も示します。
例えば、exampleの例示として2パターンを示したいことがあるかもしれません。
nameをキーとして定義したパターンの表示を切り替えることができます。
Enumを使った選択肢の定義もできます。
こうすることで定数型をつかった定義ができるので利用側も宣言的な実装が可能になります*8。

  @GET
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(
      summary = "ユーザー情報を検索します",
      operationId = "getUsersByQuery",
      description = "クエリーで指定した条件を絞り込み条件として使用します。<br>" + "条件を指定しない場合は全レコードが取得対象となります")
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = UserResponse.UserResponseListBody.class),
            examples = {
              @ExampleObject(
                  name = "default",
                  value =
                      ""
                          + "{\n"
                          + "  \"body\": [\n"
                          + "    {\n"
                          + "      \"gender\": \"OTHER\",\n"
                          + "      \"name\": \"Name:example\",\n"
                          + "      \"id\": \"100\"\n"
                          + "    }\n"
                          + "  ],\n"
                          + "  \"ok\": true\n"
                          + "}"),
              @ExampleObject(
                  name = "return 2 record",
                  value =
                      ""
                          + "{\n"
                          + "  \"body\": [\n"
                          + "    {\n"
                          + "      \"gender\": \"MALE\",\n"
                          + "      \"name\": \"Name:user name1\",\n"
                          + "      \"id\": \"1\"\n"
                          + "    },\n"
                          + "    {\n"
                          + "      \"gender\": \"OTHER\",\n"
                          + "      \"name\": \"Name:user name2\",\n"
                          + "      \"id\": \"2\"\n"
                          + "    }\n"
                          + "  ],\n"
                          + "  \"ok\": true\n"
                          + "}")
            })
      },
      responseCode = "200")
  @Parameters({
    @Parameter(
        name = "gender",
        description = "性別",
        schema =
            @Schema(
                enumeration = {"MALE", "FEMALE", "OTHER"},
                implementation = String.class)),
    @Parameter(name = "name", description = "ユーザー名", example = "user name")
  })
  public Response getUsersByQuery(
      @QueryParam("gender") Gender gender, @QueryParam("name") String name) {

ちょっと分かりにくいですが、配列になっていますね([ ] がありますね)。

クエリをつかった問い合わせ(@BeanParam)

クエリで1つ1つの変数をメソッドの引数として定義するよりも、@BeanParamで1つにまとめて使いやすくしたいものです。
できれば BeanParam先のクラスでパラメータの設定ができるとOpenAPIの記述としても共通化できるので良かったのですが、残念ながら @Parametersがメソッドにしか適用できないので冗長ではありますが、メソッド単位で同じことを記述する必要があります。

基本はベタでアノテーションで定義するのと違いはありません。
@BeanParamの要素をすべて記述するだけです。
ポイントは「in = ParameterIn.QUERY」というように、@BeanParamのアノテーションで指定したフィールドに付与したアノテーション(例えば @QueryParam)と合った定義を追記するところです*9。

  @GET
  @Path("beamparam")
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(
      summary = "ユーザー情報を検索します(BeanParamを使用)",
      description =
          "クエリーで指定した条件を絞り込み条件として使用します。<br>"
              + "条件を指定しない場合は全レコードが取得対象となります。<br>"
              + "*BeanParamによるクエリー指定をするとOpenAPIでは指定ができません。<br>")
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = UserResponse.UserResponseListBody.class),
            examples = {
              @ExampleObject(
                  name = "default",
                  value =
                      ""
                          + "{\n"
                          + "  \"body\": [\n"
                          + "    {\n"
                          + "      \"gender\": \"OTHER\",\n"
                          + "      \"name\": \"Name:example\",\n"
                          + "      \"id\": \"100\"\n"
                          + "    }\n"
                          + "  ],\n"
                          + "  \"ok\": true\n"
                          + "}")
            })
      },
      responseCode = "200")
  @Parameters({
    @Parameter(
        name = "gender",
        in = ParameterIn.QUERY,
        description = "性別",
        schema = @Schema(ref = "#/components/schemas/Gender")),
    @Parameter(
        name = "name",
        in = ParameterIn.QUERY,
        description = "ユーザー名",
        schema = @Schema(example = "User Name", implementation = String.class))
  })
  public Response getUsersByBeanParam(@BeanParam UserQueryParam userQueryParam) {

@BeanParam はJAX-RSの実装だけでOpen-APIのアノテーションはありません。

@lombok.Data
public class UserQueryParam {
  @QueryParam("gender")
  private Gender gender;

  @QueryParam("name")
  private String name;
}

POST

ポイントはリクエストボディの @ExampleObject を定義するところです。
これが OpenAPI-UIのリクエストボディの雛形になります。
例えば、複数パターンを準備したい時は複数記述をすると良いでしょう。

  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(summary = "ユーザー情報を登録します")
  @APIResponse(
      content =
          @Content(
              mediaType = MediaType.APPLICATION_JSON,
              schema = @Schema(implementation = UserResourceId.UserResponseIdBody.class),
              examples = {
                @ExampleObject(
                    name = "default",
                    value =
                        ""
                            + "{\n"
                            + "  \"body\": {\n"
                            + "    \"id\": \"57d1a3b9-bb09-42f4-9913-941de0a7d4cb\"\n"
                            + "  },\n"
                            + "  \"ok\": true\n"
                            + "}")
              }),
      responseCode = "201")
  @RequestBody(
      content =
          @Content(
              mediaType = MediaType.APPLICATION_JSON,
              examples = {
                @ExampleObject(
                    name = "default",
                    value =
                        ""
                            + "{\n"
                            + "    \"gender\": \"MALE\",\n"
                            + "    \"name\": \"Name:name-1\"\n"
                            + "}")
              }))
  public Response postUser(UserRequest userRequest) {

リクエストボディのクラスに OpenAPIの定義を記述しています。
こちらのEnum(3つの要素)は、拡張して作成したレスポンスクラスの結果と違って、定義通り、3つです。 とりあえず、クライアントとしては仕様として妥当なものが提示されているということで及第点かな?と。

@lombok.Data
@lombok.NoArgsConstructor
@Schema
public class UserRequest {

  @Schema(
      title = "性別",
      example = "OTHER",
      enumeration = {"MALE", "FEMALE", "OTHER"},
      required = true)
  private String gender;

  @Schema(title = "ユーザー名", example = "User Name", required = true)
  private String name;

  User toModel() {
    return this.toModel(null);
  }

  User toModel(String userId) {
    var model =
        User.builder()
            .userId(Objects.isNull(userId) ? null : UserId.of(userId))
            .gender(Gender.valueOf(this.gender))
            .name(Text.of(name))
            .build();
    return model;
  }
}

PUT

特にいうことは無く…

 @PUT
  @Path("{id}")
  @Consumes(MediaType.APPLICATION_JSON)
  @Operation(summary = "ユーザー情報を更新します")
  @APIResponse(responseCode = "204")
  @Parameters({
    @Parameter(
        name = "id",
        description = "ユーザーID",
        example = "57d1a3b9-bb09-42f4-9913-941de0a7d4cb")
  })
  @RequestBody(
      content =
          @Content(
              mediaType = MediaType.APPLICATION_JSON,
              examples = {
                @ExampleObject(
                    name = "default",
                    value =
                        ""
                            + "{\n"
                            + "    \"gender\": \"MALE\",\n"
                            + "    \"name\": \"Name:name-1\"\n"
                            + "}")
              }))
  public void putUser(@PathParam("id") String id, UserRequest userRequest) {

DELETE

こちらも特筆することはなく…

  @DELETE
  @Path("{id}")
  @Consumes(MediaType.APPLICATION_JSON)
  @Operation(summary = "ユーザー情報を削除します")
  @APIResponse(responseCode = "204")
  @Parameters({
    @Parameter(
        name = "id",
        description = "ユーザーID",
        example = "57d1a3b9-bb09-42f4-9913-941de0a7d4cb")
  })
  public void deleteUser(@PathParam("id") String id) {

ファイルアップロード

WebAPIとしてファイルをアップロードをするところもやっておきたいところです。

単一ファイル

例示のようにアノテーションで定義もできますが、アップロードする場合は常に同じ記述をするのでコードでスキーマ定義を作成して利用するというやり方もできます。

  @POST
  @Path("{id}/file")
  @Consumes(MediaType.MULTIPART_FORM_DATA)
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(summary = "ユーザーに関連するファイルをアップロードします")
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = BaseResponseBody.class),
            examples = @ExampleObject(name = "default", value = "{\"ok\": true}"))
      },
      responseCode = "200")
  @Parameters(@Parameter(name = "id", description = "ユーザーID"))
  @RequestBody(
      description = "アップロードファイルを選択してください",
      content =
          @Content(
              mediaType = MediaType.MULTIPART_FORM_DATA,
              schema =
                  @Schema(
                      type = SchemaType.OBJECT,
                      properties = {
                        @SchemaProperty(name = "file", type = SchemaType.STRING, format = "binary"),
                      })))
  public Response postUploadUserFile(@PathParam("id") String id, EntityPart file) {

スキーマの定義版

  @POST
  @Path("{id}/file")
  @Consumes(MediaType.MULTIPART_FORM_DATA)
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(summary = "ユーザーに関連するファイルをアップロードします")
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = BaseResponseBody.class),
            examples = @ExampleObject(name = "default", value = "{\"ok\": true}"))
      },
      responseCode = "200")
  @Parameters(@Parameter(name = "id", description = "ユーザーID"))
  @RequestBody(
      name = "file",
      description = "アップロードファイルを選択してください",
      content =
          @Content(
              mediaType = MediaType.MULTIPART_FORM_DATA,
              schema = @Schema(ref = "#/components/schemas/UploadFile")))
  public Response postCustomUploadUserFile(@PathParam("id") String id, EntityPart file) {

スキーマを定義して読み込む

定義の読み込みは OASModelReader の実装で行います。

まずはスキーマを生成するコード。
他のプロジェクトでも同じことをするのでUtilにしています。

  /**
   * 複数ファイルのアップロードを定義したスキーマを作成します.
   *
   * @param propertyName スキーマのプロパティ名
   * @return 複数ファイルのアップロードするスキーマ
   */
  public static Schema createUploadFileSchema(String propertyName) {

    return OASFactory.createSchema()
        .description("アップロードを指定するためのスキーマです.")
        .type(Schema.SchemaType.OBJECT)
        .properties(
            Map.of(
                propertyName,
                OASFactory.createSchema().type(Schema.SchemaType.STRING).format("binary")));
  }

これを OASModelReader.buildModel() の中で定義します。

var components =
    OASFactory.createComponents()
        .addSecurityScheme("access_token", securityScheme)
        .addSchema("Gender", OpenApiSchemaUtil.createEnumSchema(Gender.class))
        .addSchema("Genders", OpenApiSchemaUtil.createEnumListSchema(Gender.class))
        .addSchema("UploadFile", OpenApiSchemaUtil.createUploadFileSchema())
        .addSchema("UploadFiles", OpenApiSchemaUtil.createUploadFileListSchema());

こんな感じで独自のスキーマは1つ1つ追加します*10。

OpenAPI-UIとしては以下のようにファイルを指定できます。

複数ファイル

アノテーションでオブジェクトのリスト構造を定義することは出来ませんでした。
ここはコードでの生成一択です。

  @POST
  @Path("{id}/files")
  @Consumes(MediaType.MULTIPART_FORM_DATA)
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(summary = "ユーザーに関連するファイルを複数アップロードします")
  @Parameters(@Parameter(name = "id", description = "ユーザーID"))
  @RequestBody(
      name = "files",
      description = "アップロードファイルを選択してください",
      content =
          @Content(
              mediaType = MediaType.MULTIPART_FORM_DATA,
              schema = @Schema(ref = "#/components/schemas/UploadFiles")))
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = BaseResponseBody.class),
            examples = @ExampleObject(name = "default", value = "{\"ok\": true}"))
      },
      responseCode = "200")
  public Response postCustomUploadUserFiles(@PathParam("id") String id, List<EntityPart> files) {

ちなみにネット調べではイイ感じのものが見つからなかったので、OpenAPIの定義を見つつ「こういう感じになったらいいんじゃないかな?」と当て推量込みで試していって見つけた感じです。

  public static Schema createUploadFileListSchema(String propertyName) {

    return OASFactory.createSchema()
        .description("アップロードを複数指定するためのスキーマです.")
        .type(Schema.SchemaType.OBJECT)
        .properties(
            Map.of(
                propertyName,
                OASFactory.createSchema()
                    .type(Schema.SchemaType.ARRAY)
                    .items(
                        OASFactory.createSchema()
                            .type(Schema.SchemaType.STRING)
                            .format("binary"))));
  }

OpenAPI-UIで、複数ファイルを指定できるようになっていますね。

Enumのリストをクエリパラメータ

Enumとかオブジェクトをリストととしてクエリパラメータに定義する場合は、アノテーションでは定義できませんでした。
なので、コードで対応&OASModelReader で設定で対応しました。
Enumの name をそのまま使用する場合であればこれで良いと思います。
もしコード値を変換するようなケースがあればインターフェースをEnumにつけて(たとえば getCd みたいな)読み込むようにすれば実現可能です。

  public static Schema createEnumListSchema(Class<? extends Enum<?>> enumClass) {
    var enums = enumClass.getEnumConstants();
    return OASFactory.createSchema()
        .description(enumClass.getSimpleName() + "を複数指定するためのスキーマです.")
        .example(enums[0].name())
        .type(Schema.SchemaType.ARRAY)
        .items(
            OASFactory.createSchema()
                .type(Schema.SchemaType.STRING)
                .enumeration(Stream.of(enums).map(Enum::name).collect(Collectors.toList())));
  }

ちなみにリストではないEnumもついでにつくりました。
これがあれば enumerationを列挙しなくて良くなります*11。

  public static Schema createEnumSchema(Class<? extends Enum<?>> enumClass) {
    var enums = enumClass.getEnumConstants();
    return OASFactory.createSchema()
        .description(enumClass.getSimpleName() + "のスキーマです.")
        .example(enums[0].name())
        .type(Schema.SchemaType.STRING)
        .enumeration(Stream.of(enums).map(Enum::name).collect(Collectors.toList()));
  }

こうやってつくったスキーマ定義を refで指定します。

  @GET
  @Path("enumlist")
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(
      summary = "ユーザー情報を検索します",
      description = "クエリーで指定した条件を絞り込み条件として使用します。<br>" + "条件を指定しない場合は全レコードが取得対象となります")
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = UserResponse.UserResponseListBody.class),
            examples = {
              @ExampleObject(
                  name = "default",
                  value =
                      ""
                          + "{\n"
                          + "  \"body\": [\n"
                          + "    {\n"
                          + "      \"gender\": \"OTHER\",\n"
                          + "      \"name\": \"Name:example\",\n"
                          + "      \"id\": \"100\"\n"
                          + "    }\n"
                          + "  ],\n"
                          + "  \"ok\": true\n"
                          + "}"),
              @ExampleObject(
                  name = "return 2 record",
                  value =
                      ""
                          + "{\n"
                          + "  \"body\": [\n"
                          + "    {\n"
                          + "      \"gender\": \"MALE\",\n"
                          + "      \"name\": \"Name:user name1\",\n"
                          + "      \"id\": \"1\"\n"
                          + "    },\n"
                          + "    {\n"
                          + "      \"gender\": \"OTHER\",\n"
                          + "      \"name\": \"Name:user name2\",\n"
                          + "      \"id\": \"2\"\n"
                          + "    }\n"
                          + "  ],\n"
                          + "  \"ok\": true\n"
                          + "}")
            })
      },
      responseCode = "200")
  @Parameters({
    @Parameter(
        name = "genders",
        description = "性別(複数指定)",
        schema = @Schema(ref = "#/components/schemas/Genders")),
    @Parameter(
        name = "gender",
        description = "性別",
        schema = @Schema(ref = "#/components/schemas/Gender")),
    @Parameter(
        name = "name",
        description = "ユーザー名",
        example = "user name",
        schema = @Schema(implementation = String.class))
  })
  public Response getUsersByQueryWithListEnum(

「性別(複数指定)」の選択肢を ctrlを押しながら選択したら複数のEnum属性値を検索条件として指定できます。

Exception(2xx系以外のステータス)

やり方は色々とあると思います。

  • メソッドに1つ1つ記述する
  • OASModelReaderを使って全メソッドにつける
  • その説明用のURLを作る(今回はコレ)

一般的には上2つのいずれかだと思うのですが

  • 4xx、5xx は多くの場合、ほぼ同じ事書くだけ
  • メソッドのレスポンスとして想定できるHTTPステータスだけを書くやり方もあるけど、そうすると結局 5xxなどのシステム基盤として定義したものの扱いをどうする?というのは残ってしまう
  • 想定できる(設計する)以外のパターンの有無はサービスより深い所次第で変わってくる。全部をController単位で把握できるものでも実際のところないのでは?

と考えている中で、次に「利用側として何が欲しい?」と考えたときに

  • 4xx、5xxは共通してフロントとしてもハンドリングすることが多い
  • 共通部品で対応をするときに接続テストをしやすいものがあると嬉しい

というように考え至り、ExceptionControllerを作るという設計に落ち着きました。

全量は長いので一部抜粋です。

@Path("/openapi-http-ng-status")
@Tag(ref = "ExceptionController")
@EntryPoint
@SuppressWarnings("checkstyle:MissingJavadocMethod")
public class ExceptionController {

  @SuppressWarnings("resource")
  @GET
  @Path("{httpStatus}")
  @Produces(MediaType.APPLICATION_JSON)
  @Operation(
      summary = "HttpのNGステータスのレスポンス仕様",
      operationId = "getHttpNgStatus",
      description =
          ""
              + "多くのケースでは2xx以外のステータスはExceptionHandlerなどで一括して操作を行います.<br>\n"
              + "各API毎に4xxのステータス仕様を記載したり、共通して出力するような実装を追加することもできますが<br>\n"
              + "冗長な記載を減らすため本URLに4xxおよび5xxのAPI仕様は本URLへ集約します.\n"
              + "クライアントにて4xx,5xxのHttpステータス毎の制御確認での利用も想定しています.")
  @APIResponse(
      content = {
        @Content(
            mediaType = MediaType.APPLICATION_JSON,
            schema = @Schema(implementation = ErrorResponse.ErrorResponseBody.class),
            examples = {
              @ExampleObject(
                  name = "default",
                  value =
                      "{\n"
                          + "  \"ok\": false,\n"
                          + "  \"body\": {\n"
                          + "    \"errors\": [\n"
                          + "      {\n"
                          + "        \"message\": \"構文が無効です\",\n"
                          + "        \"messageCode\": \"HTTP_STATUS_BAD_REQUEST\"\n"
                          + "      }\n"
                          + "    ]\n"
                          + "  }\n"
                          + "}")
            })
      },
      responseCode = "400")

Code

experimentation/ee10-01-openapi at openapi · vermeerlab/experimentation · GitHub

ルートのプロジェクトで

./mvnw package

をして、全プロジェクトをビルドしてから

ee10-01-openapi プロジェクトで

./mvnw cargo:run -Ppayara

をしてから

http://localhost:8081/ee10-01-openapi/api/openapi-ui/index.html

参考

こちらにOpenAPIのメモは列挙

vermeer.hatenablog.jp

さいごに

Quarkus用のOpenAPI-UIライブラリだったり、Springだったらspringdoc-openapi だったりだと色々と「やってみて思ったこと」をライブラリが解消してくれます。
標準仕様の範疇だとその辺りは自分で作り込まないといけないので「あっちだとできるみたいなんだけどなぁ」とモヤモヤすることが多かったです。
とはいえ、実現方法が分かってくると面倒なことは面倒ですがJavaDocを書いている代わりだと割り切ってからは少し肩の力が抜けた気がします。 とにかくコツコツ試してみてやってみたいことは一応実現するところまでたどり着けたのは良かったです。 それにしてもEE系でOpenAPIの記事が本当に少ないなぁと思いました。
「ちょっとやってみた」はあるのですが、実用レベルのものが少ないというか…
個人的にはWebAPIのテスト用IFとしてOpenAPI-UIは使いやすいので、もうちょっと普及していても良さそうに思っているのですが、テスト系は請負開発だとエクセルへのコピペで納品物を作る系のWF開発としては余計な作業&結果やったことのある人が少ない、みたいな感じなんでしょうかね?*12

*1:これを見つけられたのが、今回のアレコレやってみて個人的には一番の収穫

*2:springdoc-openapi はやってくれた

*3:おまじない(?)みたいな感じで使っています

*4:少なくともPayaraでは。このあたりはQuarkusだと出来たりするみたいなので実装依存なようです

*5:この辺りはちょっと自前で頑張っても良いかもしれないと思いますが、まずは オレオレFW的なことをしないベタなやり方で出来ることを整理します

*6:これもまた、色々やってみて良かったものの1つ

*7:ゆえにライブラリのアップデートで不要になるかもしれないし、ならないかもしれない。ベストプラクティスというよりも「なんとか見つけたTIPS」です

*8:ここでは、あえてベタで定義する書き方の例としてスキーマを作成して設定するというやり方をしていません

*9:はじめ「in」属性は何に使うのか分かっていなかったのですが、べた書きで生成したものと見比べて「in」属性が足りていないことが分かって対応方法が分かりました。これが見つかって本当に良かった

*10:逆にいうと追加しないといけないのが、ちょっと残念とも言えます

*11:#/components/schemas/ を文字列で書かないといけないというのは残ってしまいますが、まぁそれでも共通化できるだけマシということで

*12:ユニットテストも作らないのと似ている理由。納品物を作る工数として積みにくい