CLOVER🍀

That was when it all began.

QuarkusでRESTEasy Reactive × OpenAPI、Swagger UI

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

QuarkusのExtensionに、OpenAPIおよびSwagger UI向けのものがあるようなので試しておきたいなと思いまして。

Using OpenAPI and Swagger UI - Quarkus

もうちょっと言うと、ドキュメントに書かれているのは通常のRESTEasyのものなので、RESTEasy Reactiveと組み合わせても動くのかな?
というのが知りたかったことです。

結論を言うとあっさりと動いてしまったので、ついでにOpenAPI、Swagger事情に疎いこともあって、このあたりも調べてみました。

OpenAPIとSwagger

そもそも、OpenAPIとは?ということで。

こちらは、OpenAPI仕様の標準化を行っているOpenAPI Initiative(OAI)のサイトです。OpenAPI仕様は、こちらからたどることができます。

Home 2024 - OpenAPI Initiative

今回参照するOpenAPIのドキュメントは、Quarkusが使用しているSmallRye OpenAPIが参照しているOpenAPI 3.0.3を見ることにします。

OpenAPI Specification v3.0.3

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

このドキュメントによると、OpenAPI仕様というのは以下の定義になります。

The OpenAPI Specification (OAS) defines a standard, language-agnostic interface to RESTful APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection.

OpenAPI Specification / Introduction

RESTful APIへの言語非依存のインターフェースを定義するための標準が、OpenAPI仕様です。

人およびコンピューターの両方が、ソースコードやドキュメントにアクセスすることなく、またネットワークトラフィックのインスペクションを
実施することで、サービスを検出したり機能を理解できたりします。

また、OpenAPI定義をドキュメント生成ツールを使ってAPIを表示したり、様々な言語向けのサーバーやクライアントのソースコードを生成、
テストケースの作成などの、ツールまわりで使用されることも視野に入っています。

An OpenAPI definition can then be used by documentation generation tools to display the API, code generation tools to generate servers and clients in various programming languages, testing tools, and many other use cases.

一方で、Swaggerという単語もあった気がします。

API Documentation & Design Tools for Teams | Swagger

OpenAPIとSwaggerの関係は、こちらを見るとわかります。

OpenAPI Specification / Appendix A: Revision History

まずはこちら。

2.0 2015-12-31 Donation of Swagger 2.0 to the OpenAPI Initiative

そして、OpenAPI 3.0.0のリリース。

3.0.0 2017-07-26 Release of the OpenAPI Specification 3.0.0

つまり、OpenAPI 3はSwaggerがOpenAPIに寄贈されてできたものになります。

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

現在のSwaggerは、OpenAPI仕様周辺のツールやサービスを提供するという役割みたいですね。

Swaggerが提供するツールは、こちら。

Why Swagger? | Swagger

OSSではSwagger Codegen、Swagger Editor、Swagger UIがあります。商用サービスとしては、SwaggerHub、SwaggerHub Enterprise、
Swagger Inspectorですね。

一方で、OAIとは直接の関連はないものの、OpenAPIに関するツールを作成しているGitHub Organizationもあります。

OpenAPI Tools · GitHub

openapi-generatorなどがあります。

これで、なんとなくOpenAPIとSwaggerの言葉の関係がざっくりわかりました。

Eclipse MicroProfile OpenAPI

次に、Eclipse MicroProfile OpenAPIについて。QuarkusのExtensionで使われているSmallRye OpenAPIは、Eclipse MicroProfile OpenAPIの
実装です。

Eclipse MicroProfile OpenAPI

このエントリーを書いている時点でのSmallRye OpenAPIのバージョンは2.1.17で、対応するEclipse MicroProfile OpenAPIのバージョンは2.0
なので、ドキュメントは2.0のものをリンクに記載しておきます。

https://github.com/eclipse/microprofile-open-api/blob/2.0/spec/src/main/asciidoc/microprofile-openapi-spec.adoc

Eclipse MicroProfile OpenAPI仕様は、JAX-RSアプリケーションからOpenAPI 3で記述されたAPI定義ドキュメントを作成するためのAPIおよび
プログラミングモデルを提供することを目的にしています。

This MicroProfile specification, called OpenAPI 1.0, aims to provide a set of Java interfaces and programming models which allow Java developers to natively produce OpenAPI v3 documents from their JAX-RS applications.

設定に関しては、Eclipse MicroProfile Configを使用して行います。

MicroProfile OpenAPI Specification / Configuration

OpenAPIドキュメントを生成は、アプリケーションに付与されているJAX-RSアノテーションを元にして行われます。

MicroProfile OpenAPI Specification / Documentation Mechanisms

JAX-RSアノテーションの情報だけで足りない場合は、Eclipse MicroProfile OpenAPIが提供するアノテーションで情報を追加するか、
作成済みのOpenAPIドキュメントファイルを取り込むこともできます。

Eclipse MicroProfile OpenAPIが提供するアノテーションは、こちら。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Annotations

作成済みのOpenAPIドキュメントファイルを使う場合は、こちら。

MicroProfile OpenAPI Specification / Documentation Mechanisms / Static OpenAPI files

すでに存在するOpenAPIドキュメントファイルを使用する場合は、以下の2種類の選択を取ることができます。

  • 完成しているOpenAPIドキュメントファイルを使用する … mp.openapi.scan.disabletrueに設定する
  • 部分的に記述されているOpenAPIドキュメントファイルを使用する … アプリケーション開発者が、アノテーションAPI、フィルターなどで拡張する必要がある

サンプルについては、Wikiに書かれています。

Home · eclipse/microprofile-open-api Wiki · GitHub

SmallRye OpenAPI

最後に、SmallRye OpenAPIについて。SmallRye OpenAPIは、Eclipse MicroProfile OpenAPIの実装です。

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

今回使用するのは2.1.17で、すでに述べていますがこのバージョンはEclipse MicroProfile OpenAPI 2.0に沿っています。

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

SmallRye OpenAPIには、JAX-RSSpring Framework、Vert.x向けの拡張があるようです。

https://github.com/smallrye/smallrye-open-api/tree/2.1.17/extension-jaxrs

https://github.com/smallrye/smallrye-open-api/tree/2.1.17/extension-spring

https://github.com/smallrye/smallrye-open-api/tree/2.1.17/extension-vertx

Quarkus SmallRye OpenAPI Extension

そして、SmallRye OpenAPIおよびSwagger UIを組み込んだQuarkusのExtensionが、SmallRye OpenAPI Extensionです。

Using OpenAPI and Swagger UI - Quarkus

SmallRye OpenAPIはJAX-RS向けの拡張があるようでしたが、Quarkusはビルド時にスキャンする仕組みを自前で持っているようです。

https://github.com/quarkusio/quarkus/tree/2.6.3.Final/extensions/smallrye-openapi

情報はざっくりこれくらいにして、最後に動かしてみましょう。

環境

今回の環境は、こちらです。

$ java --version
openjdk 17.0.1 2021-10-19
OpenJDK Runtime Environment (build 17.0.1+12-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.1+12-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.4 (9b656c72d54e5bacbed989b64718c159fe39b537)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.1, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-97-generic", arch: "amd64", family: "unix"

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

では、アプリケーションを作成してみます。

まず、今回のお題にExtensionはquarkus-smallrye-openapiになります。

$ mvn io.quarkus.platform:quarkus-maven-plugin:2.6.3.Final:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=openapi-swaggerui-example \
    -DprojectVersion=0.0.1-SNAPSHOT \
    -Dextensions="resteasy-reactive,resteasy-reactive-jackson,quarkus-smallrye-openapi"

あとは、RESTEasy Reactiveと組み合わせても大丈夫か確認したいので、resteasy-reactiveも入れておきます。

選択されたExtension。

[INFO] -----------
[INFO] selected extensions:
- io.quarkus:quarkus-resteasy-reactive
- io.quarkus:quarkus-smallrye-openapi
- io.quarkus:quarkus-resteasy-reactive-jackson

[INFO]
applying codestarts...
[INFO] 📚  java
🔨  maven
📦  quarkus
📝  config-properties
🔧  dockerfiles
🔧  maven-wrapper
🚀  resteasy-reactive-codestart

プロジェクト内へ移動。

$ cd openapi-swaggerui-example

生成されたソースコードは削除しておきます。

$ rm src/main/java/org/littlewings/* src/test/java/org/littlewings/*

リクエストやレスポンスに使うクラス。お題は書籍にしています。

src/main/java/org/littlewings/quarkus/openapi/Book.java

package org.littlewings.quarkus.openapi;

public class Book {
    String isbn;
    String title;
    int price;

    public static Book create(String isbn, String title, int price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

RESTEasy Reactiveを使用した、JAX-RSリソースクラス。

src/main/java/org/littlewings/quarkus/openapi/BookResource.java

package org.littlewings.quarkus.openapi;

import java.net.URI;
import java.util.Comparator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import org.jboss.resteasy.reactive.RestPath;
import org.jboss.resteasy.reactive.RestQuery;
import org.jboss.resteasy.reactive.RestResponse;

@Path("book")
public class BookResource {
    ConcurrentMap<String, Book> store = new ConcurrentHashMap<>();

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> find() {
        return Multi
                .createFrom()
                .iterable(
                        store
                                .values()
                                .stream()
                                .sorted(Comparator.<Book, Integer>comparing(b -> b.getPrice()).reversed())
                                .collect(Collectors.toList())
                );
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> findByIsbn(@RestPath String isbn) {
        return Uni.createFrom().item(store.get(isbn));
    }

    @GET
    @Path("query")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> findByIsbnWithQuery(@RestQuery String isbn) {
        return Uni.createFrom().item(store.get(isbn));
    }

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<RestResponse<?>> create(@RestPath String isbn, Book book) {
        return Uni
                .createFrom()
                .item(store.putIfAbsent(isbn, book))
                .onItem()
                .transform(b -> RestResponse.created(URI.create("/book/" + book.isbn)));
    }

    @DELETE
    @Path("{isbn}")
    public Uni<Book> delete(@RestPath String isbn) {
        return Uni
                .createFrom()
                .item(store.remove(isbn));
    }
}

@RestPath@RestQueryなどがOpenAPIドキュメントに反映されるか、確認したいところですね。

動作確認は、テストコードで行っておきます(アプリケーション自体の動作確認は省略します)。

src/test/java/org/littlewings/quarkus/openapi/BookResourceTest.java

package org.littlewings.quarkus.openapi;

import java.net.URL;
import java.util.List;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;

@QuarkusTest
@TestHTTPEndpoint(BookResource.class)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BookResourceTest {
    @TestHTTPEndpoint(BookResource.class)
    @TestHTTPResource
    URL url;

    List<Book> books = List.of(
            Book.create("978-4873119038", "Real World HTTP 第2版 ―歴史とコードに学ぶインターネットとウェブ技術", 3960),
            Book.create("978-4798167015", "Web APIの設計", 4180),
            Book.create("978-4297119256", "Web配信の技術―HTTPキャッシュ・リバースプロキシ・CDNを活用する", 3586)
    );

    @Test
    @Order(1)
    public void put() {
        books
                .stream()
                .forEach(book ->
                        given()
                                .contentType(ContentType.JSON)
                                .body(book)
                                .when()
                                .put(book.getIsbn())
                                .then()
                                .statusCode(Response.Status.CREATED.getStatusCode())
                                .header(HttpHeaders.LOCATION, url + "/" + book.getIsbn())
                );
    }

    @Test
    @Order(2)
    public void findAll() {
        given()
                .when()
                .get()
                .then()
                .statusCode(Response.Status.OK.getStatusCode())
                .body("$", hasSize(3))
                .body(
                        "title",
                        contains(
                                "Web APIの設計",
                                "Real World HTTP 第2版 ―歴史とコードに学ぶインターネットとウェブ技術",
                                "Web配信の技術―HTTPキャッシュ・リバースプロキシ・CDNを活用する"
                        )
                );
    }

    @Test
    @Order(3)
    public void find() {
        books.forEach(book ->
                given()
                        .pathParam("isbn", book.getIsbn())
                        .when()
                        .get("{isbn}")
                        .then()
                        .statusCode(Response.Status.OK.getStatusCode())
                        .body("title", is(book.getTitle()))
        );

        books.forEach(book ->
                given()
                        .queryParam("isbn", book.getIsbn())
                        .when()
                        .get("query")
                        .then()
                        .statusCode(Response.Status.OK.getStatusCode())
                        .body("title", is(book.getTitle()))
        );
    }

    @Test
    @Order(4)
    public void delete() {
        books.forEach(book ->
                given()
                        .pathParam("isbn", book.getIsbn())
                        .when()
                        .delete("{isbn}")
                        .then()
                        .statusCode(Response.Status.OK.getStatusCode())
        );

        given()
                .when()
                .get()
                .then()
                .statusCode(Response.Status.OK.getStatusCode())
                .body("$", empty());
    }
}

パッケージングして

$ mvn package

起動。

$ java -jar target/quarkus-app/quarkus-run.jar

この時点で、OpenAPIドキュメントを確認することができます。

Using OpenAPI and Swagger UI / Expose OpenAPI Specifications

パスは、/q/openapiです。

$ curl localhost:8080/q/openapi
---
openapi: 3.0.3
info:
  title: openapi-swaggerui-example API
  version: 0.0.1-SNAPSHOT
paths:
  /book:
    get:
      tags:
      - Book Resource
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Book'
  /book/query:
    get:
      tags:
      - Book Resource
      parameters:
      - name: isbn
        in: query
        schema:
          type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
  /book/{isbn}:
    get:
      tags:
      - Book Resource
      parameters:
      - name: isbn
        in: path
        required: true
        schema:
          type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
    put:
      tags:
      - Book Resource
      parameters:
      - name: isbn
        in: path
        required: true
        schema:
          type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Book'
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
    delete:
      tags:
      - Book Resource
      parameters:
      - name: isbn
        in: path
        required: true
        schema:
          type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
components:
  schemas:
    Book:
      type: object
      properties:
        isbn:
          type: string
        title:
          type: string
        price:
          format: int32
          type: integer

@RestPath@RestQueryで表現したものも含めて、反映されていますね。

@PUTはデータ登録時に201を返すように実装しているのですが、それはアノテーションには表現されていないので仕方ないでしょう。

OpenAPIドキュメントを返すパスは、quarkus.smallrye-openapi.pathで変更できます。

この時、パスの先頭に/を付与するかで動作が変わり、/を付与すると絶対パス/[指定したパス])になり、

src/main/resources/application.properties

quarkus.smallrye-openapi.path=/swagger

/を付与しない場合は/q/[指定したパス]というように/qからの相対パスとして扱われます。

src/main/resources/application.properties

quarkus.smallrye-openapi.path=swagger

今回は、/から指定しておきます。

再度ビルドして、起動。

$ mvn package
$ java -jar target/quarkus-app/quarkus-run.jar

パスが変わっていることを確認。

$ curl -s localhost:8080/swagger | head -n 10
---
openapi: 3.0.3
info:
  title: openapi-swaggerui-example API
  version: 0.0.1-SNAPSHOT
paths:
  /book:
    get:
      tags:
      - Book Resource

次に、Swagger UIを有効にしてみましょう。

Using OpenAPI and Swagger UI / Use Swagger UI for development

こちらは、開発用の位置づけです。

Swagger UIを有効にするには、dev(開発)モードで起動するか(テストモードでもOKのようです)

$ mvn compile quarkus:dev

productionモードでも有効にする場合は、quarkus.swagger-ui.always-includetrueに設定します。

src/main/resources/application.properties

quarkus.swagger-ui.always-include=true

この場合は、再度パッケージングして起動。

$ mvn package
$ java -jar target/quarkus-app/quarkus-run.jar

/q/swagger-ui/にアクセスすると、Swagger UIが確認できます。

パスを変更する場合は、quarkus.swagger-ui.pathで指定します。パスの先頭に/を付与するかで動作が変わるのはquarkus.smallrye-openapi.path
同じ話です。

src/main/resources/application.properties

quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/my-swagger-ui

/を付与しない場合は/q/[指定したパス]となります。

src/main/resources/application.properties

quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=my-swagger-ui

今回は、こんなところにしておきましょう。

まとめ

QuarkusのOpenAPI、Swagger UIのExtensionを、RESTEasy Reactiveと組み合わせて使ってみました。

割とあっさりと使えたのですが、合わせてOpenAPIやEclipse MicroProfile OpenAPIなどの情報も自分なりに調べられたので、良しとしましょう。
OpenAPIを取り巻くツール等については、また別途触ってみたいかなと思います。