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

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

K6による負荷試験のメモ

k6による負荷テスト入門 – cocone engineering

k6 応用例その1:ログイン認証型Webサイトへの負荷試験の一括実行

k6で秒間○回リクエストする 行ロックを取るような設計のボトルネック度をk6の負荷テストで検証してみた #JavaScript - Qiita

負荷テストツール K6 について調べてみた | sreake.com | 株式会社スリーシェイク

ITエンジニアの技術メモ: InfluxDBの導入(Windows編)

flywayのメモ

Maven Goal - Flyway - Product Documentation

Flywayのマイグレーションの管理を考えてみる(Spring Bootでのサンプル付き) - CLOVER🍀

【Java】シンプルなデータベースマイグレーションツール「flyway」を導入する - ほんじゃーねっと

Flyway使い方メモ #Java - Qiita

Flyway は複数人での開発に向かないという誤解について - tototoshi の日記

FlywayをJavaプログラムから使ってみる、その1 | GWT Center

SpringBoot × Flyway3パターン(FlywayAutoConfiguration/Java API/Mavenプラグイン) #Java - Qiita

Lombokのメモ

使い方

Stable

Lombokを使った開発ひと巡り - 覚えたら書く

Lombok - アノテーション一覧 - ぺんぎんらぼ

Lombok Experimental features - abcdefg.....

lombok こう使ってます! #Java - Qiita

Lombokの@BuilderがCSVファイル生成に役立った話 - STORES Product Blog

JavaDoc生成の際、Lombokの@Builderを使うと「エラー:シンボルを見つけられません」が発生する #javadoc - Qiita

SpotBugsが可変オブジェクトでないものを可変オブジェクトと判定してしまう場合の対処法 - エキサイト TechBlog.

Lombokを使っているときにJacocoのカバレッジから自動生成分を除外する方法 #Java - Qiita

lombok.config

lombok.extern.findbugs.addSuppressFBWarnings = true
lombok.addLombokGeneratedAnnotation = true 

OpenAPI(MicroProfile)を拡張してみる

はじめに

vermeer.hatenablog.jp

でOpenAPI(MicroProfile)の実装をしてみましたが、もう少し楽ができるようにしたいと思いました。
かといって実装そのものを拡張したり大がかりなものをつくるほどでも無い「程よい程度に」拡張をしてみました。

拡張ポイント

プロジェクト全体の定義

OpenApiModelReader(OASModelReaderの実装)に

  • Lisence
  • ドキュメント
  • セキュリティ(トークンの設定)

などのプロジェクトの基本情報を設定します。

オブジェクトのSchemaを追加

OpenApiSchemaに変換をしたいクラスと refで使用するキー値を登録します。

  public static final String Gender = prifixPath + "Gender";

キー値のペアとなる、Schema生成関数も登録します.

  // openApiのUtilは実装依存となるため実行時に決定するように関数で指定します.
  private static final Map<String, Supplier<Schema>> schemaMap =
      Map.ofEntries(
          entry(Gender, () -> OpenApiSchemaUtil.createEnumSchema(Gender.class)),
          entry(Genders, () -> OpenApiSchemaUtil.createEnumListSchema(Gender.class)),
          entry(UploadFile, () -> OpenApiSchemaUtil.createUploadFileSchema()),
          entry(UploadFiles, () -> OpenApiSchemaUtil.createUploadFileListSchema()));

実際に生成するのは OpenApiModelReader#buildModel内でOpenApiSchema.appendSchema(components);で行います。

登録したキー値は@Schemaのrefとして指定をします。

  @Parameters({
    @Parameter(name = "gender", description = "性別", schema = @Schema(ref = OpenApiSchema.Gender)),
    @Parameter(name = "name", description = "ユーザー名", example = "user name")
  })
  public Response getUsersByQuery(
      @QueryParam("gender") Gender gender, @QueryParam("name") String name) {

なにがうれしいか

スキーマの指定をクラスで指定できるので、書き間違いによる実行時エラーが回避できます。

実装

refの指定としてメソッドを使用できないため、実装としては「疑似 Enumなストラテジー」を作って対応をしました。

/** OpenApiSchemaの変換およびSchemaにrefに指定する定数を管理します. */
public class OpenApiSchema {

  private static final String prifixPath = "#/components/schemas/";

  public static final String Gender = prifixPath + "Gender";
  public static final String Genders = prifixPath + "Genders";
  public static final String UploadFile = prifixPath + "UploadFile";
  public static final String UploadFiles = prifixPath + "UploadFiles";

  // openApiのUtilは実装依存となるため実行時に決定するように関数で指定します.
  private static final Map<String, Supplier<Schema>> schemaMap =
      Map.ofEntries(
          entry(Gender, () -> OpenApiSchemaUtil.createEnumSchema(Gender.class)),
          entry(Genders, () -> OpenApiSchemaUtil.createEnumListSchema(Gender.class)),
          entry(UploadFile, () -> OpenApiSchemaUtil.createUploadFileSchema()),
          entry(UploadFiles, () -> OpenApiSchemaUtil.createUploadFileListSchema()));

  /**
   * プロパティで指定したクラスをSchemaへ変換してOpenAPIのコンポーネントへ追記します.
   *
   * @param components OpenAPIのcomponents
   */
  public static void appendSchema(Components components) {

    validate();
    int startIndex = prifixPath.length();

    schemaMap.entrySet().stream()
        .forEach(
            entrySet -> {
              var key = entrySet.getKey().substring(startIndex);
              components.addSchema(key, entrySet.getValue().get());
            });
  }

  /** Publicフィールドとスキーマの設定をするMapの整合性が取れていることを検証します. */
  private static void validate() {

    var fieldList =
        Stream.of(OpenApiSchema.class.getFields())
            .filter(f -> f.getType().isPrimitive() == false)
            .filter(f -> f.getType().isInstance(""))
            .map(f -> f.getName())
            .collect(Collectors.toSet());

    if (schemaMap.entrySet().size() != fieldList.size()) {
      throw new IllegalArgumentException(
          "public static field is not match schemaMap. append Schema must match.");
    }
  }
}

@ExampleObjectにjsonを指定

@ExampleObjectのvalueにjsonを記述をすることができますが、resource配下のjsonファイルを指定することはできません。
本来、externalValueはhttpを使用して外部リソースを参照するものですが、あまり使用することは無いと考え、externalValueにリソースパスを指定して読み込めるようにしました。

externalValue = "openapi/user/get_response_default.json"
src/main/resources/openapi/user/get_response_default.json

内部的には、externalValueのjsonを展開した結果をvalueへ転記して、OpenAPI.yamlとして、そのまま使えるようにしています。

なにがうれしいか

同じレスポンスの型を使ったExampleの記述が完結に記載できます。
またエスケープの無いjsonの記述ができるので見やすいです。

実装

OASFilterの実装であるOpenApiFilterでリクエストボディとレスポンスボディの@ExampleObjectの中身を書き換えます.

/** OpenAPIのOASFilterの実装. */
public class OpenApiFilter implements OASFilter {

  @Override
  public RequestBody filterRequestBody(RequestBody requestBody) {
    OpenApiExampleObjectUtil.convertExternalValueToValue(requestBody.getContent());
    return OASFilter.super.filterRequestBody(requestBody);
  }

  @Override
  @SuppressWarnings("checkstyle:AbbreviationAsWordInName")
  public APIResponse filterAPIResponse(APIResponse apiResponse) {
    OpenApiExampleObjectUtil.convertExternalValueToValue(apiResponse.getContent());
    return OASFilter.super.filterAPIResponse(apiResponse);
  }
}
/** OpenApiExampleObjectUtil. */
public class OpenApiExampleObjectUtil {

  /**
   * ExampleObjectのExternalValueで指定したJsonをValueとして展開します.
   *
   * <p>Contentを直接上書きします.
   *
   * <p>上書きに使用した externalValue は消去します.
   *
   * <p>valueに記述がある場合はvalueの記述を優先します.
   *
   * @param content openApiの@Content
   * @throws UncheckedIOException IOExceptionが発生したら処理を中断します.
   */
  public static void convertExternalValueToValue(Content content) {

    content.getMediaTypes().entrySet().stream()
        .filter(e -> Objects.nonNull(e))
        .forEach(
            e1 -> {
              e1.getValue().getExamples().entrySet().stream()
                  .filter(
                      e2 ->
                          Objects.isNull(e2.getValue().getValue())
                              || e2.getValue().getValue().equals(""))
                  .filter(e2 -> Objects.nonNull(e2.getValue().getExternalValue()))
                  .forEach(
                      example -> {
                        var externalValue = example.getValue().getExternalValue();

                        ClassLoader loader = OpenApiExampleObjectUtil.class.getClassLoader();

                        try (var inputStream = loader.getResourceAsStream(externalValue)) {
                          if (Objects.isNull(inputStream)) {
                            throw new FileNotFoundException(
                                "externalValue =["
                                    + externalValue
                                    + "] cloud not find resource path.");
                          }
                          String json =
                              new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
                          example.getValue().setValue(json);
                          example.getValue().setExternalValue("");
                        } catch (IOException ex) {
                          throw new UncheckedIOException(
                              "externalValue =["
                                  + externalValue
                                  + "] could not find resource path or could not read resource file.",
                              ex);
                        }
                      });
            });
  }
}

モックとしてjsonを使用する

OpenAPIというよりも、RESTfulAPIを便利にするものです。
開発初期では、インターフェースとしてのOpenAPIの定義設定にあわせて アプリケーションサーバーをモックサーバとして使用したいケースがあります。
メソッドの戻り値の型はResponseではなく、直接レスポンスを返却しています。
(ただし、実行結果のHttpが200が固定になります)

  public UserResponse.UserResponseBody getJsonUserById(@PathParam("id") String id) {
    var response =
        JsonUtil.readFromResource(
            "openapi/user/get_response_default.json", UserResponse.UserResponseBody.class);
    return response.get();
  }

なにがうれしいか

すでに作成済みの@ExampleObjectのjsonをそのまま使用することができるので楽ができます。
入力値で返却値を変えたい場合は、複数のjsonを作成して引数を元に返却を切り替えるようにするだけで対応できます。

実装

jsonの読み込みでjacksonを使用するので、pom.xmlに依存を追加します。

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.16.1</version>
    <type>jar</type>
</dependency>

モックでの使用を前提としているので、Jsonの操作時のエラーハンドリングを簡略化して*1実行時例外はあえて塗りつぶしています。

/** JsonUtil. */
public class JsonUtil {

  private static final Logger logger = Logger.getLogger(JsonUtil.class.getName());

  /**
   * Json文字列をクラスにマッピングします.
   *
   * @param <T> マッピングするクラスの型
   * @param json json文字列
   * @param classType マッピングするクラスの型
   * @return マッピングしたインスタンス.例外があった場合は {@code Optional.empty()}
   */
  public static <T> Optional<T> read(String json, Class<T> classType) {

    ObjectMapper mapper = new ObjectMapper();
    try {
      T object = mapper.readValue(json, classType);
      return Optional.of(object);
    } catch (JsonProcessingException ex) {
      logger.log(Level.SEVERE, "Json could not parse.", ex);
      return Optional.empty();
    }
  }

  /**
   * Jsonリソースをクラスにマッピングします.
   *
   * @param <T> マッピングするクラスの型
   * @param resourcePath リソースパス
   * @param classType マッピングするクラスの型
   * @return マッピングしたインスタンス.例外があった場合は {@code Optional.empty()}
   */
  public static <T> Optional<T> readFromResource(String resourcePath, Class<T> classType) {

    ClassLoader loader = Thread.currentThread().getContextClassLoader();

    try (var inputStream = loader.getResourceAsStream(resourcePath)) {
      if (Objects.isNull(inputStream)) {
        throw new FileNotFoundException(
            "resourcePath =[" + resourcePath + "] cloud not find resource path.");
      }

      String json = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
      return read(json, classType);
    } catch (IOException ex) {
      logger.log(Level.SEVERE, "resourcePath =[" + resourcePath + "] ioexception.", ex);
    }

    return Optional.empty();
  }
}

Code

experimentation/ee10-02-openapi at openapi-extend · vermeerlab/experimentation · GitHub

さいごに

色々と自動生成的なことをしようとも思ったのですが、標準機能による拡張ポイントで出来ることにあえて限定しておくほうが、EE系の場合には良いかな?と思って このくらいにしましたが、それでも随分と冗長な記述が減るのではないかな?と思っています。
ここではPayaraを使っているため、MicroProfileの実装としては正直貧弱だと思いますが、QuarkusやHelidonといったMicroProfileの実装を使えば(importにQuarkusのライブラリを使うような拡張を使えば)、もっと便利なものがすでに提供されている可能性は大いにあります。

*1:Eitherの代わりにOptionalで簡易的に処置

KarateのfeatureをOpenAPIから作成(ZenWave Karate IDE)

はじめに

OpenAPIの定義をDSL的に使って、Karateのfeatureを自動生成するVSCodeの拡張です。

基本的な使い方は拡張機能のサイトにある動画を見るのが分かりやすいので割愛します。

marketplace.visualstudio.com

ここでは自動生成された資産についての補足です。

認可トークンのファイルの出力場所

OpenAPIで認可トークンありにすると、その辺りの資産も自動生成してくれます。
karate-auth.js に認可トークンに関する共通の設定を記述できます。

ただ、このファイルの生成先のディレクトリがプロジェクトのルートでした。
これだとfeatureからのパス指定位置としてふさわしくないため、テストコードのルートとなるディレクトリにファイルを移動させました。
デフォルトはベーシック認証になっているみたいです。
今回は認可トークンをつかったロジックなどは実装していないのでコメントアウトしました。

自動生成で出来なかったところの手直し

通常のRESTfulな仕様の範囲は特にエラーにならなかったです。
ただファイルのアップロードだけは multi-part で指定してくれなかったので修正をしました*1。

  And multipart file file = {read:'test-data/FileToUpload1.txt', filename:'FileToUpload1.txt',Content-type:'mulitpart/form-data'}

Code

vermeer.hatenablog.jp

で作成したEEのアプリをCargoで起動して、karateのテストを動かします。
手順は、下記のリポジトリのREADMEに記載しているので、そちらを参考にしてください。

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

さいごに

OpenAPIをちゃんとつくると、それをDSLとして色々とできるというのは非常に良いですね。
テストコードの構造化しているところとか、よく見るサンプルよりも一歩踏み込んだ感じなので色々と学びも多かったように思います。

*1:通常のリクエストボディと同じ扱いをされる

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:ユニットテストも作らないのと似ている理由。納品物を作る工数として積みにくい

JAX-RS(Jakarta RESTful Web Services)のメモ

JAX-RS入門および実践

Jakarta EE 10 - Jakarta RESTful Web Services 3.1 変更内容まとめ - A Memorandum

JAX-RS(Jakarta RESTful Web Services) 3.1.0で、Contextアノテーションの代わりにCDIが推奨されるようになっていたという話 - CLOVER🍀

JAX-RSを使って動画ファイルをダウンロードする #JAX-RS - Qiita

JAX-RSによるExcel/CSV/PDFファイルダウンロード #Java - Qiita

JAX-RSでファイルダウンロードし、終了したらファイル削除 | GWT Center

JAX-RSで複数ファイルをアップロードするには | KATSUMI KOKUZAWA'S BLOG

JAX-RSを利用して大量データを効率的に配信する方法 - エンタープライズギークス (Enterprise Geeks)