2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Web API 仕様の公開方法について検討していたところ、MicroProfile の存在を知りました。

microprofile_7_1.png

MicroProfile はエンタープライズ Java マイクロサービスのための仕様セットです。その仕様セットに MicroProfile OpenAPI という仕様が含まれています。

その MicroProfile OpenAPI 仕様を読んでいたところ、『OpenAPI エンドポイント』というものを発見しました。このエンドポイントは、そのサーバの Web API 群の仕様について書かれた OpenAPI ドキュメントを返します。

本記事では、この OpenAPI エンドポイントの仕様と、それを自力で実装する方法についてご紹介します。

MicroProfile OpenAPI 仕様をサポートする Web アプリケーションサーバを使えば、特定の条件を満たすことにより自動的に OpenAPI エンドポイントが生成されるようです。

しかし、"fully processed" な (= 外部参照が全て解決された状態の) OpenAPI ドキュメントを META-INF/openapi.(json|yaml|yml) を置く、またはソースコード内に自動ドキュメント化のためのアノテーションを大量に記述する (もしくは MicroProfile OpenAPI 特化の OASModelReaderOASFilter を設定する) というのはかえってメンテナンス性が下がるため、また、fully processed 状態ではない OpenAPI ドキュメントを別用途で使いたかったため (参照: スキーマ定義を用いたJSONバリデーション)、手書きで OpenAPI エンドポイントを実装することにしました。

OpenAPI エンドポイントの仕様

OpenAPI エンドポイント仕様の概要は次の通りです。

項目 説明
HTTP メソッド GET
パス /openapi
クエリーパラメータ format=(YAML|JSON) (任意)
レスポンスフォーマット YAML (デフォルト) または JSON

なお、この記事で紹介する実装では、次の独自拡張をおこないます。

  • パスに .yaml.yml または .json という拡張子をつけることで、レスポンスのフォーマットを指定できる。拡張子がついていた場合、format クエリーパラメータよりも優先される。
  • format クエリーパラメータは、YAML YML または JSON という値を取ることができ、値の文字の大文字小文字は無視される。

OpenAPI エンドポイントの実装

ドキュメントフォーマットを表す列挙型

ソースコードを読みやすくするよう、ドキュメントフォーマット (YAML か JSON) を表す列挙型 (enum) を定義します。

DocumentFormat.java
import jakarta.ws.rs.core.MediaType;

enum DocumentFormat
{
    /**
     * YAML
     */
    YAML(new MediaType("application", "yaml").withCharset("UTF-8")),

    /**
     * JSON
     */
    JSON(MediaType.APPLICATION_JSON_TYPE.withCharset("UTF-8")),
    ;

    /**
     * ドキュメントフォーマットのメディアタイプ
     */
    private final MediaType mediaType;

    /**
     * ドキュメントフォーマットのメディアタイプを引数に取るコンストラクタ
     *
     * @param mediaType
     *         ドキュメントフォーマットのメディアタイプ
     */
    private DocumentFormat(MediaType mediaType)
    {
        this.mediaType = mediaType;
    }

    /**
     * ドキュメントフォーマットのメディアタイプを取得する。
     *
     * @return
     *         ドキュメントフォーマットのメディアタイプ
     */
    public MediaType getMediaType()
    {
        return mediaType;
    }
}

ドキュメントフォーマットを決定するロジック

拡張子と format クエリパラメータの値の組からドキュメントフォーマットを決定するロジックを書きます。

private static DocumentFormat determineDocumentFormat(String extension, String format)
{
    // 拡張子は format リクエストパラメータよりも優先される。
    // この動作は MicroProfile OpenAPI 仕様の一部ではなく、
    // 利便性のためにこの実装が提供する特別動作である。
    switch (extension)
    {
        case ".yaml":
        case ".yml":
            return DocumentFormat.YAML;

        case ".json":
            return DocumentFormat.JSON;
    }

    // もしも拡張子が上記のどれとも一致しない場合、その値は空文字である。
    // @Path アノテーションに記述された正規表現により、extension が
    // 他の値を取ることはない。サポートしていない拡張子がパスに付けられた
    // 場合は "404 Not Found" が返される。

    // format リクエストパラメータが指定されていない場合
    if (format == null)
    {
        // MicroProfile OpenAPI Specification
        // 5.2. Content format
        //
        //   The default format of the /openapi endpoint is YAML.
        //
        return DocumentFormat.YAML;
    }

    // MicroProfile OpenAPI Specification
    // 5.3. Query parameters
    //
    //   No query parameters are required for the /openapi endpoint. However,
    //   one suggested but optional query parameter for vendors to support is
    //   format, where the value can be either JSON or YAML, to facilitate
    //   the toggle between the default YAML format and JSON format.
    //

    // format=YAML または format=YML の場合 (大文字小文字の違いは影響しない)
    if (format.equalsIgnoreCase("YAML") || format.equalsIgnoreCase("YML"))
    {
        return DocumentFormat.YAML;
    }

    // format=JSON の場合 (大文字小文字の違いは影響しない)
    if (format.equalsIgnoreCase("JSON"))
    {
        return DocumentFormat.JSON;
    }

    // 指定された format はサポートしていない。
    String message = "The format specified by the 'format' request parameter is not supported.";
    logger.debug(message);

    // 400 Bad Request
    throw ApiResponseUtility.exceptionWithBadRequest(message);
}

※ ソースコードで利用している ApiResponseUtility の実装の紹介は割愛します。

OpenAPI ドキュメントをパースする処理

YAML で書かれた OpenAPI ドキュメントを読み込んでパースし、swagger-parser ライブラリの OpenAPI インスタンスを生成する処理を書きます。このパース処理により、OpenAPI ドキュメントが fully processed 状態になるのがポイントです。

private static OpenAPI parseDocument()
{
    // OpenAPIParser に渡すオプション
    ParseOptions options = new ParseOptions();
    options.setResolve(true);

    // OpenAPI ドキュメントをパースする
    SwaggerParseResult result =
            new OpenAPIParser().readLocation(DOCUMENT_LOCATION, null, options);

    // パース処理中に溜められたエラーメッセージ
    List<String> messages = result.getMessages();

    // エラーが発生していた場合
    if (messages != null && messages.size() != 0)
    {
        // OpenAPI ドキュメントのパースに失敗
        String message = String.format("The OpenAPI document failed to be parsed: %s", messages.get(0));
        logger.error(message);

        for (int i = 0; i < messages.size(); i++)
        {
            // OpenAPI ドキュメントのエラー
            logger.error("The OpenAPI document error: [{}] {}", i, messages.get(i));
        }

        // 500 Internal Server Error
        throw ApiResponseUtility.exceptionWithServerError(message);
    }

    // パースした OpenAPI ドキュメントを表す OpenAPI インスタンスを得る
    OpenAPI openapi = result.getOpenAPI();

    if (openapi == null)
    {
        // 想定した場所に OpenAPI ドキュメントが存在しない
        String message = "The OpenAPI document is not available at the expected location.";
        logger.error(message);

        // 500 Internal Server Error
        throw ApiResponseUtility.exceptionWithServerError(message);
    }

    return openapi;
}

swagger-parser ライブラリは pom.xml に次の dependency を追加することで利用できます。${swagger-parser.version} となっている箇所は swagger-parser ライブラリのバージョンで置き換えてください。

<dependency>
    <groupId>io.swagger.parser.v3</groupId>
    <artifactId>swagger-parser</artifactId>
    <version>${swagger-parser.version}</version>
</dependency>

YAML と JSON を生成する

OpenAPI インスタンスが作成できれば、それを元に YAML と JSON を生成するのは簡単です。それらを生成し、再利用できるように (=リクエストを受けるたびに生成し直さなくてすむように) キャッシュしておきます。

private static void buildDocuments()
{
    // OpenAPI ドキュメントをパースする
    OpenAPI openapi = parseDocument();

    // YAML フォーマットのドキュメント
    String yaml = Yaml.pretty(openapi);

    // JSON フォーマットのドキュメント
    String json = Json.pretty(openapi) + "\n";

    // ドキュメントをキャッシュする
    cachedDocuments.put(DocumentFormat.YAML, yaml);
    cachedDocuments.put(DocumentFormat.JSON, json);
}

ドキュメントフォーマットに対応するドキュメントを得る

ドキュメントフォーマットを指定して対応するドキュメントを取得する処理を書きます。生成済みのドキュメントがキャッシュされていなければ、ドキュメントを生成してキャッシュしてからドキュメントを返すようにします。

private static String obtainDocument(DocumentFormat documentFormat)
{
    // キャッシュが空であれば
    if (cachedDocuments.isEmpty())
    {
        // YAML 形式のドキュメントと JSON 形式のドキュメントを
        // 両方とも生成し、キャッシュに入れる
        buildDocuments();
    }

    // 指定されたドキュメントフォーマットのドキュメントを返す
    return cachedDocuments.get(documentFormat);
}

エントリーポイントを書く

ここまで準備できれば、あとは /openapi というパスで HTTP GET リクエストを受け付けるエントリーポイントを書くだけです。

@RequestScoped
@Path("/openapi{extension : (\\.(yaml|yml|json))?}")
public class Endpoint
{
    /**
     * OpenAPI ドキュメントのパス
     */
    private static final String DOCUMENT_LOCATION = "/schema/api/openapi.yaml";


    /**
     * このクラス用のロガー
     */
    private static final Logger logger = new Logger(Endpoint.class);


    /**
     * fully processed 状態の OpenAPI ドキュメントのキャッシュ
     */
    private static final EnumMap<DocumentFormat, String> cachedDocuments;


    static
    {
        // fully processed 状態の OpenAPI ドキュメントのキャッシュ
        cachedDocuments = new EnumMap<>(DocumentFormat.class);
    }


    @GET
    public Response openapi(
            @PathParam("extension") String extension,
            @QueryParam("format") String format)
    {
        // このエンドポイントから返す OpenAPI ドキュメントのフォーマットを決定する。
        DocumentFormat documentFormat = determineDocumentFormat(extension, format);

        // フォーマットに対応する fully processed 状態の OpenAPI ドキュメントを得る。
        String document = obtainDocument(documentFormat);

        // フォーマットに対応するメディアタイプを持つ 200 OK レスポンスを返す。
        return Response.ok(document).type(documentFormat.getMediaType()).build();
    }

まとめ

OpenAPI エンドポイントの実装 (Endpoint.java)
Endpoint.java
package com.example.api.openapi;


import java.util.EnumMap;
import java.util.List;
import jakarta.enterprise.context.RequestScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import com.example.api.ApiResponseUtility;
import com.example.logging.Logger;
import io.swagger.parser.OpenAPIParser;
import io.swagger.v3.core.util.Json;
import io.swagger.v3.core.util.Yaml;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.parser.core.models.ParseOptions;
import io.swagger.v3.parser.core.models.SwaggerParseResult;


/**
 * この Web アプリケーションの OpenAPI ドキュメントを公開するエンドポイント
 *
 * <p>
 * この実装は <a href="https://microprofile.io/">MicroProfile OpenAPI
 * Specification</a> 仕様に従っています。
 * </p>
 *
 * @see <a href="https://microprofile.io/">
 *      MicroProfile OpenAPI Specification</a>
 */
@RequestScoped
@Path("/openapi{extension : (\\.(yaml|yml|json))?}")
public class Endpoint
{
    /**
     * OpenAPI ドキュメントのパス
     */
    private static final String DOCUMENT_LOCATION = "/schema/api/openapi.yaml";


    /**
     * このクラス用のロガー
     */
    private static final Logger logger = new Logger(Endpoint.class);


    /**
     * fully processed 状態の OpenAPI ドキュメントのキャッシュ
     */
    private static final EnumMap<DocumentFormat, String> cachedDocuments;


    static
    {
        // fully processed 状態の OpenAPI ドキュメントのキャッシュ
        cachedDocuments = new EnumMap<>(DocumentFormat.class);
    }


    @GET
    public Response openapi(
            @PathParam("extension") String extension,
            @QueryParam("format") String format)
    {
        // このエンドポイントから返す OpenAPI ドキュメントのフォーマットを決定する。
        DocumentFormat documentFormat = determineDocumentFormat(extension, format);

        // フォーマットに対応する fully processed 状態の OpenAPI ドキュメントを得る。
        String document = obtainDocument(documentFormat);

        // フォーマットに対応するメディアタイプを持つ 200 OK レスポンスを返す。
        // 200 OK with the media type corresponding to the document format.
        return Response.ok(document).type(documentFormat.getMediaType()).build();
    }


    private static DocumentFormat determineDocumentFormat(String extension, String format)
    {
        // 拡張子は format リクエストパラメータよりも優先される。
        // この動作は MicroProfile OpenAPI 仕様の一部ではなく、
        // 利便性のためにこの実装が提供する特別動作である。
        switch (extension)
        {
            case ".yaml":
            case ".yml":
                return DocumentFormat.YAML;

            case ".json":
                return DocumentFormat.JSON;
        }

        // もしも拡張子が上記のどれとも一致しない場合、その値は空文字である。
        // @Path アノテーションに記述された正規表現により、extension が
        // 他の値を取ることはない。サポートしていない拡張子がパスに付けられた
        // 場合は "404 Not Found" が返される。

        // format リクエストパラメータが指定されていない場合
        if (format == null)
        {
            // MicroProfile OpenAPI Specification
            // 5.2. Content format
            //
            //   The default format of the /openapi endpoint is YAML.
            //
            return DocumentFormat.YAML;
        }

        // MicroProfile OpenAPI Specification
        // 5.3. Query parameters
        //
        //   No query parameters are required for the /openapi endpoint. However,
        //   one suggested but optional query parameter for vendors to support is
        //   format, where the value can be either JSON or YAML, to facilitate
        //   the toggle between the default YAML format and JSON format.
        //

        // format=YAML または format=YML の場合 (大文字小文字の違いは影響しない)
        if (format.equalsIgnoreCase("YAML") || format.equalsIgnoreCase("YML"))
        {
            return DocumentFormat.YAML;
        }

        // format=JSON の場合 (大文字小文字の違いは影響しない)
        if (format.equalsIgnoreCase("JSON"))
        {
            return DocumentFormat.JSON;
        }

        // 指定された format はサポートしていない。
        String message = "The format specified by the 'format' request parameter is not supported.";
        logger.debug(message);

        // 400 Bad Request
        throw ApiResponseUtility.exceptionWithBadRequest(message);
    }


    private static String obtainDocument(DocumentFormat documentFormat)
    {
        // キャッシュが空であれば
        if (cachedDocuments.isEmpty())
        {
            // YAML 形式のドキュメントと JSON 形式のドキュメントを
            // 両方とも生成し、キャッシュに入れる
            buildDocuments();
        }

        // 指定されたドキュメントフォーマットのドキュメントを返す
        return cachedDocuments.get(documentFormat);
    }


    private static void buildDocuments()
    {
        // OpenAPI ドキュメントをパースする
        OpenAPI openapi = parseDocument();

        // YAML フォーマットのドキュメント
        String yaml = Yaml.pretty(openapi);

        // JSON フォーマットのドキュメント
        String json = Json.pretty(openapi) + "\n";

        // ドキュメントをキャッシュする
        cachedDocuments.put(DocumentFormat.YAML, yaml);
        cachedDocuments.put(DocumentFormat.JSON, json);
    }


    private static OpenAPI parseDocument()
    {
        // OpenAPIParser に渡すオプション
        ParseOptions options = new ParseOptions();
        options.setResolve(true);

        // OpenAPI ドキュメントをパースする
        SwaggerParseResult result =
                new OpenAPIParser().readLocation(DOCUMENT_LOCATION, null, options);

        // パース処理中に溜められたエラーメッセージ
        List<String> messages = result.getMessages();

        // エラーが発生していた場合
        if (messages != null && messages.size() != 0)
        {
            // OpenAPI ドキュメントのパースに失敗
            String message = String.format("The OpenAPI document failed to be parsed: %s", messages.get(0));
            logger.error(message);

            for (int i = 0; i < messages.size(); i++)
            {
                // OpenAPI ドキュメントのエラー
                logger.error("The OpenAPI document error: [{}] {}", i, messages.get(i));
            }

            // 500 Internal Server Error
            throw ApiResponseUtility.exceptionWithServerError(message);
        }

        // パースした OpenAPI ドキュメントを表す OpenAPI インスタンスを得る
        OpenAPI openapi = result.getOpenAPI();

        if (openapi == null)
        {
            // 想定した場所に OpenAPI ドキュメントが存在しない
            String message = "The OpenAPI document is not available at the expected location.";
            logger.error(message);

            // 500 Internal Server Error
            throw ApiResponseUtility.exceptionWithServerError(message);
        }

        return openapi;
    }
}

/openapi/ui

MicroProfile OpenAPI 仕様には次のように書かれています。

Vendors may provide a separate interface to allow users to vizualize or browse the contents of the OpenAPI document. If such a user interface is provided, it should be made available at /openapi/ui.

すなわち、OpenAPI ドキュメントを Web ブラウザで閲覧する機能を提供する場合は、そのパスを /openapi/ui とすることが求められています。

SwaggerUIBundle を含む次のような HTML を /openapi/ui から返すようにすると、ブラウジング可能な形で OpenAPI ドキュメントが表示されるようになります。

/openapi/ui から返す HTML の例
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="description" content="SwaggerUI" />
  <title>SwaggerUI</title>
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.26.2/swagger-ui.css" />
  <link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
  <style>
    /*
     * Reduce excessive padding inside <code> tags in paragraphs.
     */
    .swagger-ui p code,
    .swagger-ui li code {
      font-size: inherit !important;
      padding: 0.1em !important;
    }

    /*
     * Use monospace font for all <input> fields.
     */
    .swagger-ui input {
      font-family: monospace;
    }
  </style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.26.2/swagger-ui-bundle.js" crossorigin></script>
<script>
  window.onload = () => {
    window.ui = SwaggerUIBundle({
      url: '/openapi',
      dom_id: '#swagger-ui',
    });
  };
</script>
</body>
</html>

※ SwaggerUIBundle が /openapi を呼び出せるよう、/openapi の実装が CORS preflight リクエストに対応できる必要があります。

おわりに

MicroProfile OpenAPI 仕様をサポートしていない Web アプリケーションサーバを使っていても、この記事でご紹介した実装により、同仕様に準拠する方法で OpenAPI ドキュメントを公開することができます。『スキーマ定義を用いたJSONバリデーション』に書いた件と併せて、OpenAPI ドキュメントの扱いはこれで一段落です。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?