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

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

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で簡易的に処置