CLOVER🍀

That was when it all began.

WildFly 33とMicroProfile OpenAPI(SmallRye OpenAPI)でOpenAPIドキュメントを生成する

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

OpenAPIドキュメントを書くためのツールをいろいろと探していたのですが、個人的にはどれも合わない感じがしたので、自分で
使う分にはコードから生成するアプローチの方がいいのかなと思いまして。

まずはMicroProfile OpenAPIを試してみることにしました。

MicroProfile OpenAPIの実装にはSmallRye OpenAPI(を同梱したWildFly 33)を使います。

MicroProfile OpenAPI

MicroProfile OpenAPIのページはこちら。

eclipse/microprofile-open-api

GitHubリポジトリーはこちら。

GitHub - eclipse/microprofile-open-api: Microprofile open api

MicroProfile OpenAPIの最新版は4.0なのですが、今回は確認にWildFly 33に同梱されているSmallRye OpenAPIを使う関係上、バージョンは
そちらに合ったものを参照します。

今回参照するMicroProfile OpenAPIのバージョンは3.1.1です。

MicroProfile OpenAPI Specification

MicroProfile OpenAPIは、Jakarta RESTful Web Services(JAX-RS)で実装されたアプリケーションからOpenAPI v3のドキュメントを
生成することを目的としたAPI仕様です。

This MicroProfile specification, called OpenAPI, aims to provide a set of Java interfaces and programming models which allow Java developers to natively produce OpenAPI v3 documents from their applications written using Jakarta RESTful Web Services (JAX-RS).

MicroProfile OpenAPI Specification / Introduction

Jakarta Bean Validation(Bean Validation)とも関係します。

MicroProfile OpenAPIが準拠するOpenAPIのバージョンは3.0.xになります。OpenAPI 3.0.xの現時点での最終版は3.0.3ですね。

OpenAPI Specification v3.0.3

MicroProfile OpenAPIをざっくり言うと、こういうものです。

  • JAX-RSやBean Validationを使って実装したコードやMicroProfile OpenAPIのアノテーションからOpenAPIドキュメントを生成する
  • 生成されたOpenAPIドキュメントはデプロイしたアプリケーション自身から確認できる
    • /openapiというパスで表示
  • MicroProfile Configと統合されており、OpenAPIドキュメントの一部を設定可能
  • OASFactoryOASFilterによる値の設定やカスタマイズが可能

あくまでOpenAPIドキュメント(YAMLおよびJSON)を生成する仕様になっていて、OpenAPIドキュメントと聞いた時にイメージする
Swagger UIのようなUI提供は必須ではありません。

JAX-RSおよびBean Validationを使用してアプリケーションを実装すると、最低限のOpenAPIドキュメントを生成することができますが、
それだけではOpenAPIドキュメントとしては情報が足りないのでMicroProfile OpenAPIのアノテーションなどで補う必要があります。

MicroProfile OpenAPIが提供するアノテーションは以下にリストアップされています。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Annotations / Quick overview of annotations

Javadocを見るのもよいでしょう。

Overview (MicroProfile OpenAPI API)

使用例はこちら。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Detailed usage of key annotations

Bean Validationのアノテーションとデータ型がどのようにOpenAPIドキュメントに反映されるかは、MicroProfile OpenAPIで
決まっているようです。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Jakarta Bean Validation Annotations

Javaでの型とOpenAPIドキュメント上のデータ型がどういうマッピングになるかは明記がないので、実装依存のようですね。

MicroProfile Configで設定できるプロパティはこちら。スキャン対象などを設定できるようです。

MicroProfile OpenAPI Specification / Configuration / List of configurable items / Core configurations

実装側の拡張プロパティも規定のprefixで許可されているようです。

MicroProfile OpenAPI Specification / Configuration / List of configurable items / Vendor extensions

作成済みのOpenAPIを使いつつ、実行時にマージすることもできるようです。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Static OpenAPI files

参考)

コードが仕様の源泉MicroProfile OpenAPI | 豆蔵デベロッパーサイト

SmallRye OpenAPI

WildFlyが同梱しているMicroProfile OpenAPIの実装がSmallRye OpenAPIです。

GitHub - smallrye/smallrye-open-api: SmallRye implementation of Eclipse MicroProfile OpenAPI

WildFly 33.0.0.2.FinalではSmallRye OpenAPI 3.10.0が含まれています。

pom.xmlを見ると、SmallRye OpenAPI 3.10.0が依存しているように宣言しているMicroProfile OpenAPIのバージョンは3.0.3なのですが、
WildFly 33.0.2.Finalに含まれているMicroProfile OpenAPIのバージョンは3.1.1なので…これは気にしないことにします。

https://github.com/smallrye/smallrye-open-api/blob/3.10.0/pom.xml#L22

Javaの型を、OpenAPIのデータ型にどのようにマッピングするかはこちらを見るとよさそうです。

https://github.com/smallrye/smallrye-open-api/blob/3.10.0/core/src/main/java/io/smallrye/openapi/runtime/util/TypeUtil.java

Bean ValidationのアノテーションをどのようにOpenAPIドキュメントに反映するかは決められていますが、実装はこのあたりのようです。

https://github.com/smallrye/smallrye-open-api/blob/3.10.0/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScanner.java

SmallRye OpenAPIによる拡張プロパティは、このあたりを見るとよいでしょう。

https://github.com/smallrye/smallrye-open-api/blob/3.10.0/core/src/main/java/io/smallrye/openapi/api/constants/OpenApiConstants.java#L36-L72

OpenAPIドキュメントのtitleやurlなどをMicroProfile Configで設定できるようです。

この他、実装としての特徴としては、JAX-RS以外にもSpringやVert.xも処理対象にできるようです。JAX-RS自体もこの仕組みの一種として
実装されているようですね。

またMavenプラグインやGradleプラグインも提供されていて、アプリケーションを動作させずともOpenAPIドキュメントを生成できます。

今回は、MicroProfile OpenAPIとしてのドキュメント生成と、MavenプラグインによるOpenAPIドキュメント生成の両方を試してみたいと
思います。

追記) UIもあったので、試してみました。

https://github.com/smallrye/smallrye-open-api/tree/3.10.0/ui

SmallRye OpenAPIのUIを使ってOpenAPIドキュメントを参照する - CLOVER🍀

WildFly MicroProfile OpenAPI Subsystem

WildFlyにMicroProfile OpenAPIとSmallRye OpenAPIが含まれているとは言ったものの、MicroProfile OpenAPIはデフォルト設定には
含まれていないので、standalone.xmlstandalone-full.xmlではなくstandalone-microprofile.xmlなどのMicroProfile用の設定で
起動する必要があります。

WildFly 33 is released!

MicroProfile OpenAPIに関する機能は、WildFly MicroProfile OpenAPI Subsystemとして提供されます。ドキュメントはこちら。

WildFly Admin Guide / Subsystem configuration / MicroProfile OpenAPI Subsystem

WildFlyのMicroProfile用の設定以外で、WildFly MicroProfile OpenAPI Subsystemを有効にすることもできます。

WildFly Admin Guide / Subsystem configuration / MicroProfile OpenAPI Subsystem / Subsystem

MicroProfile Configを使った設定例はこちら。これを見ると、OpenAPIドキュメントの提供パスを/openapi以外に変更できそうですね。

WildFly Admin Guide / Subsystem configuration / MicroProfile OpenAPI Subsystem / Configuration

ドキュメントなどを見るのはこれくらいにして、実際に試してみましょう。

環境

今回の環境はこちら。

$ java --version
openjdk 21.0.4 2024-07-16
OpenJDK Runtime Environment (build 21.0.4+7-Ubuntu-1ubuntu222.04)
OpenJDK 64-Bit Server VM (build 21.0.4+7-Ubuntu-1ubuntu222.04, mixed mode, sharing)


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

WildFlyは33.0.2.Finalを使います。

JAX-RS+Bean ValidationのみでOpenAPIドキュメントを生成してみる

まずはJAX-RSとBean ValidationのみでOpenAPIドキュメントを生成してみましょう。JAX-RSリソースクラスは、書籍と人をお題に2つ
用意することにします。

pom.xmlから。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.littlewings</groupId>
    <artifactId>microprofile-openapi-example</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <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>33.0.2.Final</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>5.11.2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>jakarta.ws.rs</groupId>
            <artifactId>jakarta.ws.rs-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.enterprise</groupId>
            <artifactId>jakarta.enterprise.cdi-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.validation</groupId>
            <artifactId>jakarta.validation-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.26.3</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.rest-assured</groupId>
            <artifactId>rest-assured</artifactId>
            <version>5.5.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>ROOT</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.4.0</version>
                <configuration>
                    <failOnMissingWebXml>false</failOnMissingWebXml>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.wildfly.plugins</groupId>
                <artifactId>wildfly-maven-plugin</artifactId>
                <version>5.0.1.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>33.0.2.Final</version>
                        <layersForJndi>
                            <layer>microprofile-openapi</layer>
                        </layersForJndi>
                    </discover-provisioning-info>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

最近はWildFly Maven PluginでWildFlyをプロビジョニングすることにしているのですが、MicroProfile OpenAPIに関するコードを含めて
いないのでWildFly Glowがレイヤーを検出しないので明示的に追加しています。

                        <layersForJndi>
                            <layer>microprofile-openapi</layer>
                        </layersForJndi>

WildFlyに直接デプロイする場合は、起動コマンドは

$ bin/standalone.sh

ではなく、以下のようにしてください。

$ bin/standalone.sh -c standalone-microprofile.xml

でないと、/openapiにアクセスしても404になります。

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;

@ApplicationPath("/")
public class RestApplication extends Application {
}

書籍を扱うJAX-RSリソースクラス。

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

package org.littlewings.wildfly.openapi.api;

import jakarta.enterprise.context.ApplicationScoped;
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 org.littlewings.wildfly.openapi.model.BookRequest;
import org.littlewings.wildfly.openapi.model.BookResponse;

import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

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

    @PUT
    @Path("/{isbn13}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public BookResponse register(@Size(min = 14, max = 14) @PathParam("isbn13") String isbn13, @Valid BookRequest bookRequest) {
        return store.compute(isbn13, (i, before) -> new BookResponse(
                        i,
                        bookRequest.title(),
                        bookRequest.price(),
                        bookRequest.publishDate()
                )
        );
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<BookResponse> findAll() {
        return store.values().stream().sorted(Comparator.comparing(BookResponse::price).reversed()).toList();
    }

    @GET
    @Path("/{isbn13}")
    @Produces(MediaType.APPLICATION_JSON)
    public BookResponse findByIsbn13(@Size(min = 14, max = 14) @PathParam("isbn13") String isbn13) {
        return store.get(isbn13);
    }

    @DELETE
    @Path("/{isbn13}")
    public void delete(@Size(min = 14, max = 14) @PathParam("isbn13") String isbn13) {
        store.remove(isbn13);
    }
}

データをまったく持たないのもなんなので、今回は簡単にインメモリーで保持することにしました。

書籍のリクエスト。

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

package org.littlewings.wildfly.openapi.model;

import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

import java.time.LocalDate;

public record BookRequest(
        @NotEmpty
        @Size(max = 100)
        String title,
        @NotNull
        @Positive
        Integer price,
        @NotNull
        @JsonFormat(pattern = "uuuu-MM-dd")
        LocalDate publishDate
) {
}

書籍のレスポンス。

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

package org.littlewings.wildfly.openapi.model;

import com.fasterxml.jackson.annotation.JsonFormat;

import java.time.LocalDate;

public record BookResponse(
        String isbn13,
        String title,
        Integer price,
        @JsonFormat(pattern = "uuuu-MM-dd")
        LocalDate publishDate
) {
}

人を扱うJAX-RSをリソースクラス。

src/main/java/org/littlewings/wildfly/openapi/api/PeopleResource.java

package org.littlewings.wildfly.openapi.api;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
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 org.littlewings.wildfly.openapi.model.PersonRequest;
import org.littlewings.wildfly.openapi.model.PersonResponse;

import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@Path("/people")
@ApplicationScoped
public class PeopleResource {
    private ConcurrentMap<Integer, PersonResponse> store = new ConcurrentHashMap<>();

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public PersonResponse create(@Valid PersonRequest request) {
        String identity = request.firstName() + ":" + request.lastName();

        if (store.values().stream().filter(r -> identity.equals(r.firstName() + ":" + r.lastName())).findFirst().isEmpty()) {
            return store.compute(store.size() + 1, (i, before) -> new PersonResponse(
                    i,
                    request.firstName(),
                    request.lastName(),
                    request.age()
            ));
        }

        return store.values().stream().filter(r -> identity.equals(r.firstName() + ":" + r.lastName())).findFirst().get();
    }

    @PUT
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public PersonResponse update(@Positive @PathParam("id") Integer id, @Valid PersonRequest request) {
        return store.compute(id, (i, before) -> new PersonResponse(
                i,
                request.firstName(),
                request.lastName(),
                request.age()
        ));
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<PersonResponse> findAll() {
        return store.values().stream().sorted(Comparator.comparing(PersonResponse::id)).toList();
    }

    @GET
    @Path("/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public PersonResponse findById(@Positive @PathParam("id") Integer id) {
        return store.get(id);
    }

    @DELETE
    @Path("/{id}")
    public void delete(@Positive @PathParam("id") Integer id) {
        store.remove(id);
    }
}

人に関するリクエスト。

src/main/java/org/littlewings/wildfly/openapi/model/PersonRequest.java

package org.littlewings.wildfly.openapi.model;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;

public record PersonRequest(
        @NotEmpty
        @Size(max = 10)
        String firstName,
        @NotEmpty
        @Size(max = 10)
        String lastName,
        @NotNull
        @PositiveOrZero
        Integer age
) {
}

人のレスポンス。

src/main/java/org/littlewings/wildfly/openapi/model/PersonResponse.java

package org.littlewings.wildfly.openapi.model;

public record PersonResponse(
        Integer id,
        String firstName,
        String lastName,
        Integer age
) {
}

書籍のリクエスト/レスポンスでLocalDateを使ったので、Jacksonをカスタマイズします。

src/main/java/org/littlewings/wildfly/openapi/provider/ObjectMapperProvider.java

package org.littlewings.wildfly.openapi.provider;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.ext.ContextResolver;
import jakarta.ws.rs.ext.Provider;

@Provider
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ObjectMapperProvider implements ContextResolver<ObjectMapper> {
    @Override
    public ObjectMapper getContext(Class<?> type) {
        return new ObjectMapper().findAndRegisterModules();
    }
}

WildFlyにjackson-datatype-jsr310は含まれているのですが、これを明示的に呼び出す必要があります。

RESTEasy 6.2.11.Final以降を使えばこのコードは不要になりそうですが、WildFly 33.0.2.Finalに含まれるRESTEasyは6.2.10.Finalなので…。

The Jackson Provider should add the Jackson Jdk8Module and JavaTimeModule by default

また、そもそもWildFlyJSONを扱う時のデフォルトはJSON-Bのようなのですが、テストコードで使っていたREST AssuredがJakarta JSON-Bに
対応しておらず、テストコードとプロダクションコードでモデルを共有しようとしたのでJacksonを使うことになりました…。

Support JSON-B 3.0 (Jakarta EE 10) · Issue #1651 · rest-assured/rest-assured · GitHub

蛇足でした。

テストコードはこちらです。

src/test/java/org/littlewings/wildfly/openapi/api/BooksResourceTest.java

package org.littlewings.wildfly.openapi.api;

import io.restassured.common.mapper.TypeRef;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import org.littlewings.wildfly.openapi.model.BookRequest;
import org.littlewings.wildfly.openapi.model.BookResponse;

import java.time.LocalDate;
import java.util.List;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;

class BooksResourceTest {
    @Test
    void validation() {
        // path
        given()
                .pathParam("isbn13", "hoge")
                .contentType(ContentType.JSON)
                .body(new BookRequest("test", 100, LocalDate.of(2024, 1, 13)))
                .when()
                .put("http://localhost:8080/books/{isbn13}")
                .then()
                .assertThat()
                .statusCode(400);

        // require
        given()
                .pathParam("isbn13", "123-4567890123")
                .contentType(ContentType.JSON)
                .body(new BookRequest(null, 100, LocalDate.of(2024, 1, 13)))
                .when()
                .put("http://localhost:8080/books/{isbn13}")
                .then()
                .assertThat()
                .statusCode(400);
        given()
                .pathParam("isbn13", "123-4567890123")
                .contentType(ContentType.JSON)
                .body(new BookRequest("test", null, LocalDate.of(2024, 1, 13)))
                .when()
                .put("http://localhost:8080/books/{isbn13}")
                .then()
                .assertThat()
                .statusCode(400);
        given()
                .pathParam("isbn13", "123-4567890123")
                .contentType(ContentType.JSON)
                .body(new BookRequest("test", 100, null))
                .when()
                .put("http://localhost:8080/books/{isbn13}")
                .then()
                .assertThat()
                .statusCode(400);

        // negative price
        given()
                .pathParam("isbn13", "123-4567890123")
                .contentType(ContentType.JSON)
                .body(new BookRequest("test", -1, LocalDate.of(2024, 1, 13)))
                .when()
                .put("http://localhost:8080/books/{isbn13}")
                .then()
                .assertThat()
                .statusCode(400);
    }

    @Test
    void simple() {
        // register
        assertThat(
                given()
                        .pathParam("isbn13", "978-4297126858")
                        .contentType(ContentType.JSON)
                        .body(new BookRequest("プロになるJava - 仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", 3278, LocalDate.of(2022, 3, 19)))
                        .when()
                        .put("http://localhost:8080/books/{isbn13}")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(BookResponse.class)
        ).isEqualTo(new BookResponse("978-4297126858", "プロになるJava - 仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", 3278, LocalDate.of(2022, 3, 19)));

        assertThat(
                given()
                        .pathParam("isbn13", "978-4621303252")
                        .contentType(ContentType.JSON)
                        .body(new BookRequest("Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30)))
                        .when()
                        .put("http://localhost:8080/books/{isbn13}")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(BookResponse.class)
        ).isEqualTo(new BookResponse("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30)));

        assertThat(
                given()
                        .pathParam("isbn13", "978-4297136130")
                        .contentType(ContentType.JSON)
                        .body(new BookRequest("プロになるためのSpring入門 - ゼロからの開発力養成講座", 3960, LocalDate.of(2023, 7, 12)))
                        .when()
                        .put("http://localhost:8080/books/{isbn13}")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(BookResponse.class)
        ).isEqualTo(new BookResponse("978-4297136130", "プロになるためのSpring入門 - ゼロからの開発力養成講座", 3960, LocalDate.of(2023, 7, 12)));

        // get
        assertThat(
                given()
                        .pathParam("isbn13", "978-4297126858")
                        .when()
                        .get("http://localhost:8080/books/{isbn13}")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(BookResponse.class)
        ).isEqualTo(new BookResponse("978-4297126858", "プロになるJava - 仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", 3278, LocalDate.of(2022, 3, 19)));

        // list
        assertThat(
                given()
                        .when()
                        .get("http://localhost:8080/books")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(new TypeRef<List<BookResponse>>() {
                        })
        ).isEqualTo(
                List.of(
                        new BookResponse("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30)),
                        new BookResponse("978-4297136130", "プロになるためのSpring入門 - ゼロからの開発力養成講座", 3960, LocalDate.of(2023, 7, 12)),
                        new BookResponse("978-4297126858", "プロになるJava - 仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", 3278, LocalDate.of(2022, 3, 19))
                )
        );

        // delete
        given()
                .pathParam("isbn13", "978-4621303252")
                .when()
                .delete("http://localhost:8080/books/{isbn13}")
                .then()
                .assertThat()
                .statusCode(204);

        // list
              assertThat(
                      given()
                              .when()
                              .get("http://localhost:8080/books")
                              .then()
                              .assertThat()
                              .statusCode(200)
                              .contentType(ContentType.JSON)
                              .extract()
                              .as(new TypeRef<List<BookResponse>>() {
                              })
              ).isEqualTo(
                      List.of(
                              new BookResponse("978-4297136130", "プロになるためのSpring入門 - ゼロからの開発力養成講座", 3960, LocalDate.of(2023, 7, 12)),
                              new BookResponse("978-4297126858", "プロになるJava - 仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", 3278, LocalDate.of(2022, 3, 19))
                      )
              );
    }
}

src/test/java/org/littlewings/wildfly/openapi/api/PeopleResourceTest.java

package org.littlewings.wildfly.openapi.api;

import java.util.List;

import io.restassured.common.mapper.TypeRef;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import org.littlewings.wildfly.openapi.model.PersonRequest;
import org.littlewings.wildfly.openapi.model.PersonResponse;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;

class PeopleResourceTest {
    @Test
    void validation() {
        // path
        given()
                .pathParam("id", "hoge")
                .contentType(ContentType.JSON)
                .body(new PersonRequest("firstName", "lastName", 23))
                .when()
                .put("http://localhost:8080/people/{id}")
                .then()
                .assertThat()
                .statusCode(404);
        given()
                .pathParam("id", "0")
                .contentType(ContentType.JSON)
                .body(new PersonRequest("firstName", "lastName", 23))
                .when()
                .put("http://localhost:8080/people/{id}")
                .then()
                .assertThat()
                .statusCode(400);

        // require
        given()
                .contentType(ContentType.JSON)
                .body(new PersonRequest(null, "lastName", 23))
                .when()
                .post("http://localhost:8080/people")
                .then()
                .assertThat()
                .statusCode(400);
        given()
                .contentType(ContentType.JSON)
                .body(new PersonRequest("firstName", null, 23))
                .when()
                .post("http://localhost:8080/people")
                .then()
                .assertThat()
                .statusCode(400);
        given()
                .contentType(ContentType.JSON)
                .body(new PersonRequest("firstName", "lastName", null))
                .when()
                .post("http://localhost:8080/people")
                .then()
                .assertThat()
                .statusCode(400);

        // negative age
        given()
                .contentType(ContentType.JSON)
                .body(new PersonRequest("firstName", "lastName", -1))
                .when()
                .post("http://localhost:8080/people")
                .then()
                .assertThat()
                .statusCode(400);
    }

    @Test
    void simple() {
        // register
        assertThat(
                given()
                        .contentType(ContentType.JSON)
                        .body(new PersonRequest("カツオ", "磯野", 11))
                        .when()
                        .post("http://localhost:8080/people")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(PersonResponse.class)
        ).isEqualTo(new PersonResponse(1, "カツオ", "磯野", 11));

        assertThat(
                given()
                        .contentType(ContentType.JSON)
                        .body(new PersonRequest("ワカメ", "磯野", 9))
                        .when()
                        .post("http://localhost:8080/people")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(PersonResponse.class)
        ).isEqualTo(new PersonResponse(2, "ワカメ", "磯野", 9));

        assertThat(
                given()
                        .contentType(ContentType.JSON)
                        .body(new PersonRequest("タマ", "磯野", 0))
                        .when()
                        .post("http://localhost:8080/people")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(PersonResponse.class)
        ).isEqualTo(new PersonResponse(3, "タマ", "磯野", 0));

        // get
        assertThat(
                given()
                        .pathParam("id", 1)
                        .when()
                        .get("http://localhost:8080/people/{id}")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(PersonResponse.class)
        ).isEqualTo(new PersonResponse(1, "カツオ", "磯野", 11));

        // list
        assertThat(
                given()
                        .when()
                        .get("http://localhost:8080/people")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(new TypeRef<List<PersonResponse>>() {
                        })
        ).isEqualTo(
                List.of(
                        new PersonResponse(1, "カツオ", "磯野", 11),
                        new PersonResponse(2, "ワカメ", "磯野", 9),
                        new PersonResponse(3, "タマ", "磯野", 0)
                )
        );

        // update
        assertThat(
                given()
                        .pathParam("id", 3)
                        .contentType(ContentType.JSON)
                        .body(new PersonRequest("タマ", "磯野", 1))
                        .when()
                        .put("http://localhost:8080/people/{id}")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(PersonResponse.class)
        ).isEqualTo(new PersonResponse(3, "タマ", "磯野", 1));

        // list
        assertThat(
                given()
                        .when()
                        .get("http://localhost:8080/people")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(new TypeRef<List<PersonResponse>>() {
                        })
        ).isEqualTo(
                List.of(
                        new PersonResponse(1, "カツオ", "磯野", 11),
                        new PersonResponse(2, "ワカメ", "磯野", 9),
                        new PersonResponse(3, "タマ", "磯野", 1)
                )
        );

        // delete
        given()
                .pathParam("id", 3)
                .when()
                .delete("http://localhost:8080/people/{id}")
                .then()
                .assertThat()
                .statusCode(204);

        // list
        assertThat(
                given()
                        .when()
                        .get("http://localhost:8080/people")
                        .then()
                        .assertThat()
                        .statusCode(200)
                        .contentType(ContentType.JSON)
                        .extract()
                        .as(new TypeRef<List<PersonResponse>>() {
                        })
        ).isEqualTo(
                List.of(
                        new PersonResponse(1, "カツオ", "磯野", 11),
                        new PersonResponse(2, "ワカメ", "磯野", 9)
                )
        );
    }
}

こちらでアプリケーションをデプロイ済みのWildFlyを起動して

$ mvn wildfly:run -DskipTests=true

テストすることで動作確認ができます。

$ mvn test

機能の確認は今回の本筋ではないので、ここまでにしておきます。

この状態で以下のURLにアクセスすると

$ curl localhost:8080/openapi

OpenAPIドキュメントが得られます。

---
openapi: 3.0.3
info:
  title: ROOT.war
  version: "1.0"
servers:
- url: /
paths:
  /books:
    get:
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BookResponse'
  /books/{isbn13}:
    get:
      parameters:
      - name: isbn13
        in: path
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
    put:
      parameters:
      - name: isbn13
        in: path
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookRequest'
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
    delete:
      parameters:
      - name: isbn13
        in: path
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
      responses:
        "204":
          description: No Content
  /people:
    get:
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PersonResponse'
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonRequest'
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponse'
  /people/{id}:
    get:
      parameters:
      - name: id
        in: path
        required: true
        schema:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponse'
    put:
      parameters:
      - name: id
        in: path
        required: true
        schema:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonRequest'
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponse'
    delete:
      parameters:
      - name: id
        in: path
        required: true
        schema:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
      responses:
        "204":
          description: No Content
components:
  schemas:
    BookRequest:
      required:
      - title
      - price
      - publishDate
      type: object
      properties:
        title:
          maxLength: 100
          minLength: 1
          type: string
        price:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
        publishDate:
          format: date
          type: string
          example: 2022-03-10
    BookResponse:
      type: object
      properties:
        isbn13:
          type: string
        title:
          type: string
        price:
          format: int32
          type: integer
        publishDate:
          format: date
          type: string
          example: 2022-03-10
    PersonRequest:
      required:
      - firstName
      - lastName
      - age
      type: object
      properties:
        firstName:
          maxLength: 10
          minLength: 1
          type: string
        lastName:
          maxLength: 10
          minLength: 1
          type: string
        age:
          format: int32
          minimum: 0
          type: integer
    PersonResponse:
      type: object
      properties:
        id:
          format: int32
          type: integer
        firstName:
          type: string
        lastName:
          type: string
        age:
          format: int32
          type: integer

よさそうですね。バリデーションなども反映されていそうです。

  schemas:
    BookRequest:
      required:
      - title
      - price
      - publishDate
      type: object
      properties:
        title:
          maxLength: 100
          minLength: 1
          type: string
        price:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
        publishDate:
          format: date
          type: string
          example: 2022-03-10

ちなみに、Acceptを調整するとJSONで取得することもできます。

$ curl -H 'Accept: application/json' localhost:8080/openapi

それはそれとして、このあたりが微妙だったりなどやっぱりOpenAPIとしては情報が足りません。

openapi: 3.0.3
info:
  title: ROOT.war
  version: "1.0"
servers:
- url: /

このあたりはMicroProfile OpenAPIのアノテーションを使って追加していきましょう。

MicroProfile OpenAPIのアノテーションを加えてOpenAPIドキュメントを生成してみる

ここまで作成したコードに、MicroProfile OpenAPIのアノテーションを使って情報を追加していきます。

まずはwildfly-microprofileを追加して

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.wildfly.bom</groupId>
                <artifactId>wildfly-ee-with-tools</artifactId>
                <version>33.0.2.Final</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.wildfly.bom</groupId>
                <artifactId>wildfly-microprofile</artifactId>
                <version>33.0.2.Final</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>5.11.2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

依存関係にmicroprofile-openapi-apiを追加します。

        <dependency>
            <groupId>org.eclipse.microprofile.openapi</groupId>
            <artifactId>microprofile-openapi-api</artifactId>
            <scope>provided</scope>
        </dependency>

これでMicroProfile OpenAPIのAPIが使えるようになります。

WildFly Glowでも検出できるようになるので、自分でmicroprofile-openapiレイヤーを追加する必要はなくなります。

            <plugin>
                <groupId>org.wildfly.plugins</groupId>
                <artifactId>wildfly-maven-plugin</artifactId>
                <version>5.0.1.Final</version>
                <executions>
                    <execution>
                        <id>package</id>
                        <goals>
                            <goal>package</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <bootable-jar-name>${project.artifactId}-${project.version}-server-bootable.jar</bootable-jar-name>
                    <overwrite-provisioned-server>true</overwrite-provisioned-server>
                    <discover-provisioning-info>
                        <version>33.0.2.Final</version>
                    </discover-provisioning-info>
                </configuration>
            </plugin>

あとは作成したソースコードにMicroProfile OpenAPIのアノテーションを加えていきます。アノテーションの説明は省略します。
長くなりすぎるので…。

全体の情報。

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("/")
public class RestApplication extends Application {
}

こちらを参考に。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Detailed usage of key annotations / Servers

JAX-RSリソース。いわゆるエンドポイントですね。

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

package org.littlewings.wildfly.openapi.api;

import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import jakarta.enterprise.context.ApplicationScoped;
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 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;
import org.littlewings.wildfly.openapi.model.BookRequest;
import org.littlewings.wildfly.openapi.model.BookResponse;

@Path("/books")
@ApplicationScoped
@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/api/PeopleResource.java

package org.littlewings.wildfly.openapi.api;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
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 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;
import org.littlewings.wildfly.openapi.model.PersonRequest;
import org.littlewings.wildfly.openapi.model.PersonResponse;

import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@Path("/people")
@ApplicationScoped
@Tag(name = "people")
public class PeopleResource {
    private ConcurrentMap<Integer, PersonResponse> store = new ConcurrentHashMap<>();

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    @Operation(operationId = "registerPerson", summary = "人を登録する", description = "人を登録する")
    @APIResponse(responseCode = "200", description = "人が登録できたことを表す")
    @APIResponse(responseCode = "400", description = "バリデーションでNGになったことを表す")
    public PersonResponse create(@Valid @RequestBody(required = true, description = "登録する人のデータ") PersonRequest request) {
        String identity = request.firstName() + ":" + request.lastName();

        if (store.values().stream().filter(r -> identity.equals(r.firstName() + ":" + r.lastName())).findFirst().isEmpty()) {
            return store.compute(store.size() + 1, (i, before) -> new PersonResponse(
                    i,
                    request.firstName(),
                    request.lastName(),
                    request.age()
            ));
        }

        return store.values().stream().filter(r -> identity.equals(r.firstName() + ":" + r.lastName())).findFirst().get();
    }

    @PUT
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    @Operation(operationId = "updatePerson", summary = "指定されたIDに対応する人を更新する", description = "指定されたIDに対応する人を更新する")
    @APIResponse(responseCode = "200", description = "人が更新できたことを表す")
    @APIResponse(responseCode = "400", description = "バリデーションでNGになったことを表す")
    public PersonResponse update(@Positive @PathParam("id") @Parameter(description = "ID", example = "1") Integer id,
                                 @Valid @RequestBody(required = true, description = "更新する人のデータ") PersonRequest request) {
        return store.compute(id, (i, before) -> new PersonResponse(
                i,
                request.firstName(),
                request.lastName(),
                request.age()
        ));
    }

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

    @GET
    @Path("/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    @Operation(operationId = "findPersonById", summary = "指定されたIDに対応する人を取得する", description = "指定されたIDに対応する人を取得する")
    @APIResponse(responseCode = "200", description = "指定された人が取得できたことを表す")
    @APIResponse(responseCode = "404", description = "指定された人が存在しなかったことを表す")
    public PersonResponse findById(@Positive @PathParam("id") @Parameter(description = "ID", example = "1") Integer id) {
        return store.get(id);
    }

    @DELETE
    @Path("/{id}")
    @Operation(operationId = "deletePersonById", summary = "指定されたIDに対応する人を削除する", description = "指定されたIDに対応する人を削除する")
    @APIResponse(responseCode = "204", description = "人が削除されていることを表す")
    public void delete(@Positive @PathParam("id") @Parameter(description = "ID", example = "1") Integer id) {
        store.remove(id);
    }
}

参考にするのはこのあたり。

リクエスト、レスポンスに関するクラス。

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

package org.littlewings.wildfly.openapi.model;

import java.time.LocalDate;

import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;

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

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

package org.littlewings.wildfly.openapi.model;

import java.time.LocalDate;

import com.fasterxml.jackson.annotation.JsonFormat;
import org.eclipse.microprofile.openapi.annotations.media.Schema;

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

src/main/java/org/littlewings/wildfly/openapi/model/PersonRequest.java

package org.littlewings.wildfly.openapi.model;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;

@Schema(description = "登録する人に関するリクエスト")
public record PersonRequest(
        @NotEmpty
        @Size(max = 10)
        @Schema(description = "登録する人の名", example = "カツオ")
        String firstName,
        @NotEmpty
        @Size(max = 10)
        @Schema(description = "登録する人の姓", example = "磯野")
        String lastName,
        @NotNull
        @PositiveOrZero
        @Schema(description = "登録する人の年齢", example = "11")
        Integer age
) {
}

`src/main/java/org/littlewings/wildfly/openapi/model/PersonResponse.java

package org.littlewings.wildfly.openapi.model;

import org.eclipse.microprofile.openapi.annotations.media.Schema;

@Schema(description = "登録されている人")
public record PersonResponse(
        @Schema(description = "ID", example = "1")
        Integer id,
        @Schema(description = "名", example = "カツオ")
        String firstName,
        @Schema(description = "姓", example = "磯野")
        String lastName,
        @Schema(description = "年齢", example = "11")
        Integer age
) {
}

こちらはこのあたりを参考に。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Detailed usage of key annotations / Schema

アノテーションの使い方は、こちらも参考に。

コードが仕様の源泉MicroProfile OpenAPI | 豆蔵デベロッパーサイト

確認してみましょう。

$ curl localhost:8080/openapi

こうなりました。

---
openapi: 3.0.3
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
- name: people
paths:
  /books:
    get:
      tags:
      - book
      summary: 登録された書籍をすべて返却する
      description: 登録された書籍を価格の降順にソートしてすべて返却する
      operationId: findAllBooks
      responses:
        "200":
          description: 登録されている書籍の件数に関わらず、書籍を返却したことを表す。0件の場合は空の配列を返す
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BookResponse'
  /books/{isbn13}:
    get:
      tags:
      - book
      summary: 指定されたISBNに対応する書籍を取得する
      description: 指定されたISBNに対応する書籍を取得する
      operationId: findBookByIsbn13
      parameters:
      - name: isbn13
        in: path
        description: ISBN
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
        example: 123-4567890123
      responses:
        "200":
          description: 指定された書籍が取得できたことを表す
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
        "404":
          description: 指定された書籍が存在しなかったことを表す
    put:
      tags:
      - book
      summary: 指定されたISBNに書籍を登録する
      description: 指定されたISBNに対応する書籍を登録する
      operationId: registerBook
      parameters:
      - name: isbn13
        in: path
        description: ISBN
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
        example: 123-4567890123
      requestBody:
        description: 登録する書籍データ
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookRequest'
        required: true
      responses:
        "200":
          description: 書籍が登録できたことを表す
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
        "400":
          description: バリデーションでNGになったことを表す
    delete:
      tags:
      - book
      summary: 指定されたISBNに対応する書籍を削除する
      description: 指定されたISBNに対応する書籍を削除する
      operationId: deleteBookByIsbn13
      parameters:
      - name: isbn13
        in: path
        description: ISBN
        required: true
        schema:
          maxLength: 14
          minLength: 14
          type: string
        example: 123-4567890123
      responses:
        "204":
          description: 書籍が削除されていることを表す
  /people:
    get:
      tags:
      - people
      summary: 登録されている人をすべて返却する
      description: 登録された人をIDの昇順にソートしてすべて返却する
      operationId: findAllPeople
      responses:
        "200":
          description: 登録されている書籍の件数に関わらず、書籍を返却したことを表す。0件の場合は空の配列を返す
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PersonResponse'
    post:
      tags:
      - people
      summary: 人を登録する
      description: 人を登録する
      operationId: registerPerson
      requestBody:
        description: 登録する人のデータ
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonRequest'
        required: true
      responses:
        "200":
          description: 人が登録できたことを表す
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponse'
        "400":
          description: バリデーションでNGになったことを表す
  /people/{id}:
    get:
      tags:
      - people
      summary: 指定されたIDに対応する人を取得する
      description: 指定されたIDに対応する人を取得する
      operationId: findPersonById
      parameters:
      - name: id
        in: path
        description: ID
        required: true
        schema:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
        example: 1
      responses:
        "200":
          description: 指定された人が取得できたことを表す
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponse'
        "404":
          description: 指定された人が存在しなかったことを表す
    put:
      tags:
      - people
      summary: 指定されたIDに対応する人を更新する
      description: 指定されたIDに対応する人を更新する
      operationId: updatePerson
      parameters:
      - name: id
        in: path
        description: ID
        required: true
        schema:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
        example: 1
      requestBody:
        description: 更新する人のデータ
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonRequest'
        required: true
      responses:
        "200":
          description: 人が更新できたことを表す
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponse'
        "400":
          description: バリデーションでNGになったことを表す
    delete:
      tags:
      - people
      summary: 指定されたIDに対応する人を削除する
      description: 指定されたIDに対応する人を削除する
      operationId: deletePersonById
      parameters:
      - name: id
        in: path
        description: ID
        required: true
        schema:
          format: int32
          minimum: 0
          exclusiveMinimum: true
          type: integer
        example: 1
      responses:
        "204":
          description: 人が削除されていることを表す
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
    PersonRequest:
      description: 登録する人に関するリクエスト
      required:
      - firstName
      - lastName
      - age
      type: object
      properties:
        firstName:
          description: 登録する人の名
          maxLength: 10
          minLength: 1
          type: string
          example: カツオ
        lastName:
          description: 登録する人の姓
          maxLength: 10
          minLength: 1
          type: string
          example: 磯野
        age:
          format: int32
          description: 登録する人の年齢
          minimum: 0
          type: integer
          example: 11
    PersonResponse:
      description: 登録されている人
      type: object
      properties:
        id:
          format: int32
          description: ID
          type: integer
          example: 1
        firstName:
          description:type: string
          example: カツオ
        lastName:
          description:type: string
          example: 磯野
        age:
          format: int32
          description: 年齢
          type: integer
          example: 11

このままだとちょっとわかりにくいので、Swagger UIで見てみましょう。

Swagger UI

それっぽい感じになったのではないでしょうか。

とにかくアノテーションだらけになるので、JAX-RSリソースクラスに関してはアノテーションを付けるのはインターフェースにして
実装するクラスは別にするなどした方が見通しがよくなる気がしますね…。

追記)SmallRye OpenAPIにはUIもあったので、試してみました。

SmallRye OpenAPIのUIを使ってOpenAPIドキュメントを参照する - CLOVER🍀

SmallRye OpenAPI Maven Pluginを使ってOpenAPIドキュメントを生成する

最後は、SmallRye OpenAPI Maven Pluginを使ってOpenAPIドキュメントを生成してみましょう。

https://github.com/smallrye/smallrye-open-api/tree/3.10.0/tools/maven-plugin

こんな感じで設定すると、コンパイル時にOpenAPIドキュメントを生成できます。

            <plugin>
                <groupId>io.smallrye</groupId>
                <artifactId>smallrye-open-api-maven-plugin</artifactId>
                <version>3.10.0</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>generate-schema</goal>
                        </goals>
                        <phase>compile</phase>
                    </execution>
                </executions>
            </plugin>

実行。

$ mvn compile

ログ。

[INFO] --- smallrye-open-api:3.10.0:generate-schema (default) @ microprofile-openapi-example ---
[INFO] Wrote the schema files to /path/to/microprofile-openapi-example/target/generated

出力先はデフォルトでtarget/generatedになり、生成されるのはYAMLJSONの2つです。

$ ls -l target/generated
合計 28
-rw-rw-r-- 1 xxxxx xxxxx 12504 10月 13 22:29 openapi.json
-rw-rw-r-- 1 xxxxx xxxxx  9043 10月 13 22:29 openapi.yaml

今回はいろいろとMicroProfile OpenAPIのアノテーションで情報を補完したので、実行時に取得できるOpenAPIドキュメントとこうやって
Mavenプラグインで生成したOpenAPIドキュメントの結果は同じになります。

最初のJAX-RSとBean ValidationのAPIだけを使った場合だと、実行時に得られる情報で差が出ます。具体的にはこんな感じですね。

4c4
<   title: ROOT.war
---
>   title: Generated API
6,7d5
< servers:
< - url: /

出力先などの調整は、設定を参照。

SmallRye OpenAPI Maven Plugin / Configuration options

特定のゴールに紐付けない場合は

            <plugin>
                <groupId>io.smallrye</groupId>
                <artifactId>smallrye-open-api-maven-plugin</artifactId>
                <version>3.10.0</version>
            </plugin>

smallrye-open-api:generate-schemaを実行するとよいでしょう。

$ mvn smallrye-open-api:generate-schema

ただ、アプリケーションの内容を解析して動作するので、少なくともcompileは実行しておかないとほぼ空のOpenAPIドキュメントが
生成されてしまうことになります。

おわりに

MicroProfile OpenAPIとその実装であるSmallRye OpenAPIを試してみました。

それなりなOpenAPIドキュメントができあがったのはよかったのですが、これはこれで慣れるまでけっこう大変ですね…。
それに、ちゃんと情報を入れようとすると大量のアノテーションを書いていくことになるので、そのあたりも難しいです。

思っていたほどより楽にはならないかもしれませんが、それでもOpenAPIドキュメントを直接書くよりは楽…でしょうか?