これは、なにをしたくて書いたもの?
OpenAPIドキュメントを書くためのツールをいろいろと探していたのですが、個人的にはどれも合わない感じがしたので、自分で
使う分にはコードから生成するアプローチの方がいいのかなと思いまして。
まずはMicroProfile OpenAPIを試してみることにしました。
MicroProfile OpenAPIの実装にはSmallRye OpenAPI(を同梱したWildFly 33)を使います。
MicroProfile OpenAPI
MicroProfile OpenAPIのページはこちら。
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ですね。
MicroProfile OpenAPIをざっくり言うと、こういうものです。
- JAX-RSやBean Validationを使って実装したコードやMicroProfile OpenAPIのアノテーションからOpenAPIドキュメントを生成する
- 生成されたOpenAPIドキュメントはデプロイしたアプリケーション自身から確認できる
/openapi
というパスで表示
- MicroProfile Configと統合されており、OpenAPIドキュメントの一部を設定可能
OASFactory
やOASFilter
による値の設定やカスタマイズが可能
あくまでOpenAPIドキュメント(YAMLおよびJSON)を生成する仕様になっていて、OpenAPIドキュメントと聞いた時にイメージする
Swagger UIのようなUI提供は必須ではありません。
JAX-RSおよびBean Validationを使用してアプリケーションを実装すると、最低限のOpenAPIドキュメントを生成することができますが、
それだけではOpenAPIドキュメントとしては情報が足りないのでMicroProfile OpenAPIのアノテーションなどで補う必要があります。
MicroProfile OpenAPIが提供するアノテーションは以下にリストアップされています。
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で設定できるプロパティはこちら。スキャン対象などを設定できるようです。
実装側の拡張プロパティも規定の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のデータ型にどのようにマッピングするかはこちらを見るとよさそうです。
Bean ValidationのアノテーションをどのようにOpenAPIドキュメントに反映するかは決められていますが、実装はこのあたりのようです。
SmallRye OpenAPIによる拡張プロパティは、このあたりを見るとよいでしょう。
OpenAPIドキュメントのtitleやurlなどをMicroProfile Configで設定できるようです。
この他、実装としての特徴としては、JAX-RS以外にもSpringやVert.xも処理対象にできるようです。JAX-RS自体もこの仕組みの一種として
実装されているようですね。
- https://github.com/smallrye/smallrye-open-api/tree/3.10.0/extension-jaxrs
- https://github.com/smallrye/smallrye-open-api/tree/3.10.0/extension-spring
- https://github.com/smallrye/smallrye-open-api/tree/3.10.0/extension-vertx
またMavenプラグインやGradleプラグインも提供されていて、アプリケーションを動作させずともOpenAPIドキュメントを生成できます。
- https://github.com/smallrye/smallrye-open-api/tree/3.10.0/tools/maven-plugin
- https://github.com/smallrye/smallrye-open-api/tree/3.10.0/tools/gradle-plugin
今回は、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.xml
やstandalone-full.xml
ではなくstandalone-microprofile.xml
などのMicroProfile用の設定で
起動する必要があります。
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
また、そもそもWildFlyでJSONを扱う時のデフォルトは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 { }
こちらを参考に。
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); } }
参考にするのはこのあたり。
- MicroProfile OpenAPI Specification / Documentation Mechanisms / Detailed usage of key annotations / Operation
- MicroProfile OpenAPI Specification / Documentation Mechanisms / Detailed usage of key annotations / RequestBody
リクエスト、レスポンスに関するクラス。
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 | 豆蔵デベロッパーサイト
確認してみましょう。
$ 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で見てみましょう。
それっぽい感じになったのではないでしょうか。
とにかくアノテーションだらけになるので、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
になり、生成されるのはYAMLとJSONの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ドキュメントを直接書くよりは楽…でしょうか?