CLOVER🍀

That was when it all began.

WildFly 36 × SmallRye OpenAPI 4.0で出力するOpenAPIドキュメントのバージョンを3.1、3.0に切り替える

これは、なにをしたくて書いたもの?

先日、WildFly 36.0.0.Finalがリリースされました。

WildFly 36 is released!

WildFly 35でもMicroProfile 7.0の一部を実装していたのですが、36でMicroProfile 7.0のTCKが通ったようなので少しずつ
見ていこうと思います。

https://github.com/wildfly/certifications/blob/MP7.0/WildFly_36.0.0.Final/microprofile-7.0/microprofile-7.0-full-certification.adoc

今回はMicroProfile OpenAPIを扱います。

MicroProfile OpenAPI 4.0

MicroProfile 7.0に含まれるMicroProfile OpenAPI仕様のバージョンは、4.0です。

現時点では、正確には4.0.2ですね。

MicroProfile OpenAPI Specification

MicroProfile OpenAPI 4.0と、その前のMicroProfile OpenAPI 3.1での変更点はこちらです。

Release Notes / Release Notes for MicroProfile OpenAPI 4.0

大きな変更点は、扱うOpenAPIのバージョンが3.0から3.1になったことではないでしょうか。

/openapi endpoint now serves documentation in OpenAPI v3.1 format

MicroProfile OpenAPI 4.0には、これに伴う変更が多数含まれています。また、仕様書を見る限りはMicroProfile OpenAPI 4.0が
OpenAPI 3.0に対応している様子はありません。

OpenAPI 3.0と3.1には、互換性のない変更があります。OpenAPI 3.0と3.1の変更点のサマリーはこちらです。

Migrating from OpenAPI 3.0 to 3.1.0 - OpenAPI Initiative

SmallRye OpenAPI 4.0

WildFlyのMicroProfile OpenAPIの実装は、SmallRye OpenAPIです。SmallRye OpenAPI 4.0で、MicroProfile OpenAPI 4.0に
対応しています。

前述のとおりOpenAPI 3.1と3.0には互換性がなく、3.0.3もよく使われるバージョンなのでOpenAPI 3.1のみだとちょっと
困るのかなと思っていたのですが、どうやらSmallRye OpenAPI 4.0ではOpenAPI 3.1と3.0の両方を扱えるようです。

Allow 4.0 to generate OpenAPI v3.0 as well as OpenAPI v3.1 · Issue #1891 · smallrye/smallrye-open-api · GitHub

Support generation of OpenAPI v3.0 by Azquelt · Pull Request #1918 · smallrye/smallrye-open-api · GitHub

使用するプロパティはmp.openapi.extensions.smallrye.openapiです。

今回、こちらを試してみることにしました。

環境

今回の環境はこちら。

$ java --version
openjdk 21.0.6 2025-01-21
OpenJDK Runtime Environment (build 21.0.6+7-Ubuntu-124.04.1)
OpenJDK 64-Bit Server VM (build 21.0.6+7-Ubuntu-124.04.1, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 21.0.6, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "6.8.0-57-generic", arch: "amd64", family: "unix"

サンプルアプリケーションを作成する

まずはJakarta RESTful Web Services(JAX-RS)とMicroProfile OpenAPIを使ったサンプルアプリケーションを作成します。

Maven依存関係などはこちら。

    <packaging>war</packaging>

    <properties>
        <maven.compiler.release>21</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.wildfly.bom</groupId>
                <artifactId>wildfly-ee-with-tools</artifactId>
                <version>36.0.0.Final</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.wildfly.bom</groupId>
                <artifactId>wildfly-expansion</artifactId>
                <version>36.0.0.Final</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.ws.rs</groupId>
            <artifactId>jakarta.ws.rs-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.validation</groupId>
            <artifactId>jakarta.validation-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.json.bind</groupId>
            <artifactId>jakarta.json.bind-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.eclipse.microprofile.openapi</groupId>
            <artifactId>microprofile-openapi-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>io.smallrye</groupId>
            <artifactId>smallrye-open-api-ui</artifactId>
            <version>4.0.9</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>ROOT</finalName>
        <plugins>
            <plugin>
                <groupId>org.wildfly.plugins</groupId>
                <artifactId>wildfly-maven-plugin</artifactId>
                <version>5.1.2.Final</version>
                <executions>
                    <execution>
                        <id>package</id>
                        <goals>
                            <goal>package</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <overwrite-provisioned-server>true</overwrite-provisioned-server>
                    <discover-provisioning-info>
                        <version>36.0.0.Final</version>
                    </discover-provisioning-info>
                </configuration>
            </plugin>
        </plugins>
    </build>

せっかくなので、SmallRye OpenAPIが提供するSwagger UIもつけています。

        <dependency>
            <groupId>io.smallrye</groupId>
            <artifactId>smallrye-open-api-ui</artifactId>
            <version>4.0.9</version>
            <scope>runtime</scope>
        </dependency>

JAX-RSの有効化。

src/main/java/org/littlewings/wildfly/openapi/RestApplication.java

package org.littlewings.wildfly.openapi;

import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition;
import org.eclipse.microprofile.openapi.annotations.info.Info;
import org.eclipse.microprofile.openapi.annotations.servers.Server;

@OpenAPIDefinition(
        info = @Info(
                title = "My Sample REST API",
                version = "0.0.1"
        ),
        servers = @Server(
                description = "My Sample REST API Server description",
                url = "http://localhost:8080"
        )
)
@ApplicationPath("/api")
public class RestApplication extends Application {
}

JAX-RSリソースクラス。

src/main/java/org/littlewings/wildfly/openapi/BooksResource.java

package org.littlewings.wildfly.openapi;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;

@Path("/books")
@Tag(name = "book")
public class BooksResource {
    private ConcurrentMap<String, BookResponse> store = new ConcurrentHashMap<>();

    @PUT
    @Path("/{isbn13}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    @Operation(operationId = "registerBook", summary = "指定されたISBNに書籍を登録する", description = "指定されたISBNに対応する書籍を登録する")
    @APIResponse(responseCode = "200", description = "書籍が登録できたことを表す")
    @APIResponse(responseCode = "400", description = "バリデーションでNGになったことを表す")
    public BookResponse register(@Size(min = 14, max = 14) @PathParam("isbn13") @Parameter(description = "ISBN", example = "123-4567890123") String isbn13,
                                 @Valid @RequestBody(required = true, description = "登録する書籍データ") BookRequest bookRequest) {
        return store.compute(isbn13, (i, before) -> new BookResponse(
                        i,
                        bookRequest.title(),
                        bookRequest.price(),
                        bookRequest.publishDate()
                )
        );
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Operation(operationId = "findAllBooks", summary = "登録された書籍をすべて返却する", description = "登録された書籍を価格の降順にソートしてすべて返却する")
    @APIResponse(responseCode = "200", description = "登録されている書籍の件数に関わらず、書籍を返却したことを表す。0件の場合は空の配列を返す")
    public List<BookResponse> findAll() {
        return store.values().stream().sorted(Comparator.comparing(BookResponse::price).reversed()).toList();
    }

    @GET
    @Path("/{isbn13}")
    @Produces(MediaType.APPLICATION_JSON)
    @Operation(operationId = "findBookByIsbn13", summary = "指定されたISBNに対応する書籍を取得する", description = "指定されたISBNに対応する書籍を取得する")
    @APIResponse(responseCode = "200", description = "指定された書籍が取得できたことを表す")
    @APIResponse(responseCode = "404", description = "指定された書籍が存在しなかったことを表す")
    public BookResponse findByIsbn13(@Size(min = 14, max = 14) @PathParam("isbn13") @Parameter(description = "ISBN", example = "123-4567890123") String isbn13) {
        return store.get(isbn13);
    }

    @DELETE
    @Path("/{isbn13}")
    @Operation(operationId = "deleteBookByIsbn13", summary = "指定されたISBNに対応する書籍を削除する", description = "指定されたISBNに対応する書籍を削除する")
    @APIResponse(responseCode = "204", description = "書籍が削除されていることを表す")
    public void delete(@Size(min = 14, max = 14) @PathParam("isbn13") @Parameter(description = "ISBN", example = "123-4567890123") String isbn13) {
        store.remove(isbn13);
    }
}

リクエストを表すモデル。

src/main/java/org/littlewings/wildfly/openapi/BookRequest.java

package org.littlewings.wildfly.openapi;

import jakarta.json.bind.annotation.JsonbDateFormat;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.eclipse.microprofile.openapi.annotations.media.Schema;

@Schema(description = "登録する書籍に関するリクエスト")
public record BookRequest(
        @NotEmpty
        @Size(max = 100)
        @Schema(description = "登録する書籍のタイトル", examples = "Javaの本")
        String title,
        @NotNull
        @Positive
        @Schema(description = "登録する書籍の価格", examples = "1500")
        Integer price,
        @NotNull
        @JsonbDateFormat("uuuu-MM-dd")
        @Schema(description = "登録する書籍の出版日", examples = "2024-10-13")
        LocalDate publishDate
) {
}

レスポンスを表すモデル。

src/main/java/org/littlewings/wildfly/openapi/BookResponse.java

package org.littlewings.wildfly.openapi;

import jakarta.json.bind.annotation.JsonbDateFormat;
import java.time.LocalDate;
import org.eclipse.microprofile.openapi.annotations.media.Schema;

@Schema(description = "登録されている書籍")
public record BookResponse(
        @Schema(description = "ISBN", examples = "123-4567890123")
        String isbn13,
        @Schema(description = "書籍のタイトル", examples = "Javaの本")
        String title,
        @Schema(description = "書籍の価格", examples = "1500")
        Integer price,
        @JsonbDateFormat("uuuu-MM-dd")
        @Schema(description = "書籍の出版日", examples = "2024-10-13")
        LocalDate publishDate
) {
}

これでサンプルアプリケーションの準備は完了です。

OpenAPI 3.1.0のOpenAPIドキュメントを生成する

まずはOpenAPI 3.1.0のOpenAPIドキュメントを生成してみましょう。

WildFlyを起動。

$ mvn wildfly:run

SmallRye OpenAPIのUIを含めているので、/openapi-uiでSwagger UIを確認できます。

YAMLで見てみましょう。

$ curl localhost:8080/openapi

返ってきたOpenAPIドキュメントはこちら。OpenAPI 3.1.0ですね。

---
openapi: 3.1.0
components:
  schemas:
    BookRequest:
      description: 登録する書籍に関するリクエスト
      type: object
      required:
      - title
      - price
      - publishDate
      properties:
        title:
          type: string
          description: 登録する書籍のタイトル
          examples:
          - Javaの本
          maxLength: 100
          minLength: 1
        price:
          type: integer
          format: int32
          description: 登録する書籍の価格
          examples:
          - 1500
          exclusiveMinimum: 0
        publishDate:
          type: string
          format: date
          examples:
          - 2024-10-13
          description: 登録する書籍の出版日
    BookResponse:
      description: 登録されている書籍
      type: object
      properties:
        isbn13:
          type: string
          description: ISBN
          examples:
          - 123-4567890123
        title:
          type: string
          description: 書籍のタイトル
          examples:
          - Javaの本
        price:
          type: integer
          format: int32
          description: 書籍の価格
          examples:
          - 1500
        publishDate:
          type: string
          format: date
          examples:
          - 2024-10-13
          description: 書籍の出版日
info:
  title: My Sample REST API
  version: 0.0.1
servers:
- url: http://localhost:8080
  description: My Sample REST API Server description
tags:
- name: book
paths:
  /api/books:
    get:
      summary: 登録された書籍をすべて返却する
      description: 登録された書籍を価格の降順にソートしてすべて返却する
      operationId: findAllBooks
      tags:
      - book
      responses:
        "200":
          description: 登録されている書籍の件数に関わらず、書籍を返却したことを表す。0件の場合は空の配列を返す
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/BookResponse"
  /api/books/{isbn13}:
    put:
      summary: 指定されたISBNに書籍を登録する
      description: 指定されたISBNに対応する書籍を登録する
      operationId: registerBook
      tags:
      - book
      parameters:
      - description: ISBN
        example: 123-4567890123
        name: isbn13
        in: path
        required: true
        schema:
          type: string
          minLength: 14
          maxLength: 14
      requestBody:
        description: 登録する書籍データ
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookRequest"
      responses:
        "200":
          description: 書籍が登録できたことを表す
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookResponse"
        "400":
          description: バリデーションでNGになったことを表す
    get:
      summary: 指定されたISBNに対応する書籍を取得する
      description: 指定されたISBNに対応する書籍を取得する
      operationId: findBookByIsbn13
      tags:
      - book
      parameters:
      - description: ISBN
        example: 123-4567890123
        name: isbn13
        in: path
        required: true
        schema:
          type: string
          minLength: 14
          maxLength: 14
      responses:
        "200":
          description: 指定された書籍が取得できたことを表す
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookResponse"
        "404":
          description: 指定された書籍が存在しなかったことを表す
    delete:
      summary: 指定されたISBNに対応する書籍を削除する
      description: 指定されたISBNに対応する書籍を削除する
      operationId: deleteBookByIsbn13
      tags:
      - book
      parameters:
      - description: ISBN
        example: 123-4567890123
        name: isbn13
        in: path
        required: true
        schema:
          type: string
          minLength: 14
          maxLength: 14
      responses:
        "204":
          description: 書籍が削除されていることを表す

OpenAPI 3.0.3のOpenAPIドキュメントを生成する

では、OpenAPI 3.0.3のOpenAPIドキュメントを生成してみます。

これを行うには、MicroProfile OpenAPIの実装であるSmallRye OpenAPIの拡張プロパティを使う必要があります。

smallryeがSmallRye OpenAPIのvendor prefixになります。こんな感じで指定。

src/main/resources/META-INF/microprofile-config.properties

mp.openapi.extensions.smallrye.openapi = 3.0.3

SmallRye OpenAPIの拡張プロパティはこちらのソースコードで確認することになります。

https://github.com/smallrye/smallrye-open-api/blob/4.0.9/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java

再度WildFlyを起動。

$ mvn wildfly:run

Swagger UIで確認すると、OASの表示が3.0になっています。

YAMLで確認してみましょう。

---
openapi: 3.0.3
components:
  schemas:
    BookRequest:
      description: 登録する書籍に関するリクエスト
      required:
      - title
      - price
      - publishDate
      type: object
      properties:
        title:
          description: 登録する書籍のタイトル
          maxLength: 100
          minLength: 1
          type: string
          example: Javaの本
        price:
          format: int32
          description: 登録する書籍の価格
          minimum: 0
          exclusiveMinimum: true
          type: integer
          example: 1500
        publishDate:
          format: date
          description: 登録する書籍の出版日
          type: string
          example: 2024-10-13
    BookResponse:
      description: 登録されている書籍
      type: object
      properties:
        isbn13:
          description: ISBN
          type: string
          example: 123-4567890123
        title:
          description: 書籍のタイトル
          type: string
          example: Javaの本
        price:
          format: int32
          description: 書籍の価格
          type: integer
          example: 1500
        publishDate:
          format: date
          description: 書籍の出版日
          type: string
          example: 2024-10-13
info:
  title: My Sample REST API
  version: 0.0.1
servers:
- url: http://localhost:8080
  description: My Sample REST API Server description
tags:
- name: book
paths:
  /api/books:
    get:
      summary: 登録された書籍をすべて返却する
      description: 登録された書籍を価格の降順にソートしてすべて返却する
      operationId: findAllBooks
      tags:
      - book
      responses:
        "200":
          description: 登録されている書籍の件数に関わらず、書籍を返却したことを表す。0件の場合は空の配列を返す
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/BookResponse"
  /api/books/{isbn13}:
    put:
      summary: 指定されたISBNに書籍を登録する
      description: 指定されたISBNに対応する書籍を登録する
      operationId: registerBook
      tags:
      - book
      parameters:
      - description: ISBN
        example: 123-4567890123
        name: isbn13
        in: path
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
      requestBody:
        description: 登録する書籍データ
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookRequest"
      responses:
        "200":
          description: 書籍が登録できたことを表す
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookResponse"
        "400":
          description: バリデーションでNGになったことを表す
    get:
      summary: 指定されたISBNに対応する書籍を取得する
      description: 指定されたISBNに対応する書籍を取得する
      operationId: findBookByIsbn13
      tags:
      - book
      parameters:
      - description: ISBN
        example: 123-4567890123
        name: isbn13
        in: path
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
      responses:
        "200":
          description: 指定された書籍が取得できたことを表す
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BookResponse"
        "404":
          description: 指定された書籍が存在しなかったことを表す
    delete:
      summary: 指定されたISBNに対応する書籍を削除する
      description: 指定されたISBNに対応する書籍を削除する
      operationId: deleteBookByIsbn13
      tags:
      - book
      parameters:
      - description: ISBN
        example: 123-4567890123
        name: isbn13
        in: path
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
      responses:
        "204":
          description: 書籍が削除されていることを表す

OpenAPI 3.0.3になっていますね。

openapi: 3.0.3

OpenAPI 3.1と3.0での変更点を確認する

これだけ見ると、バージョン表記が変わっただけでは?とも見えるので、OpenAPI 3.1と3.0で変わったところも見てみましょう。
ちょうど変更点にあたるものを含めているので。

OpenAPI 3.1ではexclusiveMinimumというキーワードは、指定した値の下限を含まないという意味になります。
こちらがOpenAPI 3.1.0で生成したOpenAPIドキュメントの抜粋です。

        price:
          type: integer
          format: int32
          description: 登録する書籍の価格
          examples:
          - 1500
          exclusiveMinimum: 0

OpenAPI 3.0ではexclusiveMinimumbooleanになっていて、別途minimumの指定が必要です。OpenAPI 3.0.3で生成した
OpenAPIドキュメントの抜粋はこちら。

        price:
          format: int32
          description: 登録する書籍の価格
          minimum: 0
          exclusiveMinimum: true
          type: integer
          example: 1500

ちゃんとバージョンに合わせて出力内容が変わっていることが確認できました。

おわりに

リリースされたばかりのWildFly 36を使って、SmallRye OpenAPIが出力するOpenAPIドキュメントのバージョンを切り替えて
みました。

MicroProfile OpenAPI 4.0がOpenAPI 3.1をターゲットにしていたのは仕様書からわかっていたので、OpenAPI 3.0.3を
使いたい時にはどうするんだろう?と思っていたところに、この拡張を前々から見つけていたので今回試してみました。

まだしばらくはOpenAPI 3.1と3.0が並行して使われると思うので、用途に応じてこういった機能を頼ることになるのかなと
思います。