CLOVER🍀

That was when it all began.

OpenAPI Generatorを使って、Spring Web MVCのエンドポイントを生成する

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

OpenAPI Generatorを使って、OpenAPIの定義ファイルからSpring Web MVCのエンドポイントを生成してみようかな、ということで。

OpenAPI Generator

OpenAPI Generatorは、OpenAPIの定義ファイルからクライアントやサーバー、ドキュメントを生成するツールです。

Hello from OpenAPI Generator

平静を保ち、コードを生成せよ 〜 OpenAPI Generator誕生の背景と軌跡 〜 / gunmaweb34 - Speaker Deck

CLIやMavenプラグイン、Gradleプラグイン、npmを使ったCLI、SaaSなどがあり、様々な方法で生成することができます。

設定まわり。

Global Properties

Configuration Options

今回は、Mavenプラグインを使って生成したいと思います。

Plugins

ジェネレーターにも種類があり、クライアント、サーバー、ドキュメント、スキーマ定義、CONFIGと様々な種類があります。

Generators List

今回は、サーバーのSpring Web MVCを対象にします。

設定は、こちらを参照ですね。

Documentation for the spring Generator

ただ、ソースコードでの設定項目も合わせて見た方がいいような気もしますね…。

https://github.com/OpenAPITools/openapi-generator/blob/v6.2.1/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java

こちらのページの内容は、バージョンが最新とは限らないようです。

Plugins

生成の際に使われるテンプレートはMustacheで、こちらに配置されているようです。

https://github.com/OpenAPITools/openapi-generator/tree/v6.2.1/modules/openapi-generator/src/main/resources

Springの時に使われるテンプレートは、こちら。

https://github.com/OpenAPITools/openapi-generator/tree/v6.2.1/modules/openapi-generator/src/main/resources/JavaSpring

テンプレートについては、カスタマイズや自分で用意したりもできるようですが、今回は扱いません。

Using Templates

Customization

では、試してみましょう。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu120.04)
OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.5, 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-135-generic", arch: "amd64", family: "unix"

使用するOpenAPI定義

今回使用するOpenAPIの定義ファイルは、こちらにします。openapi-definitionというディレクトリ内に配置することにしました。

openapi-definition/openapi.yaml

---
openapi: 3.0.3
info:
  title: OpenAPI Definition Example
  version: 0.0.1
servers:
- url: http://localhost:8080
  description: Auto generated value
- url: http://0.0.0.0:8080
  description: Auto generated value
tags:
- name: book
  description: Book operations
paths:
  /books:
    get:
      tags:
      - book
      summary: List books
      operationId: listBooks
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BookResponse'
    post:
      tags:
      - book
      summary: Create book
      operationId: createBook
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookRequest'
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
  /books/{isbn}:
    get:
      tags:
      - book
      summary: Get book by isbn
      operationId: getBook
      parameters:
      - name: isbn
        in: path
        required: true
        schema:
          type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
    delete:
      tags:
      - book
      summary: Delete book by isbn
      operationId: deleteBook
      parameters:
      - name: isbn
        in: path
        required: true
        schema:
          type: string
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
components:
  schemas:
    BookRequest:
      required:
      - isbn
      - title
      - price
      type: object
      properties:
        isbn:
          type: string
          example: 978-4621303252
        title:
          type: string
          example: Effective Java 第3版
        price:
          format: int32
          type: integer
          example: 4400
        tags:
          type: array
          items:
            type: string
          example:
          - java
          - programming
    BookResponse:
      type: object
      properties:
        isbn:
          type: string
          example: 978-4621303252
        title:
          type: string
          example: Effective Java 第3版
        price:
          format: int32
          type: integer
          example: 4400
        tags:
          type: array
          items:
            type: string
          example:
          - java
          - programming

お題は書籍です。こちらを元にして、Spring Web MVCのエンドポイントを生成しましょう。

Spring Bootプロジェクトを作成する

では、Spring Bootプロジェクトを作成します。Spring Boot 3.0を使い、依存関係にはwebのみ入れておきます。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=3.0.0 \
  -d javaVersion=17 \
  -d type=maven-project \
  -d name=openapi-generator-example \
  -d groupId=org.littlewings \
  -d artifactId=openapi-generator-example \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.openapi \
  -d dependencies=web \
  -d baseDir=openapi-generator-example | tar zxvf -

プロジェクト内に移動。

$ cd openapi-generator-example

この時点でのMaven依存関係など。

        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-web</artifactId>
                </dependency>

                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

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

$ rm src/main/java/org/littlewings/spring/openapi/OpenapiGeneratorExampleApplication.java src/test/java/org/littlewings/spring/openapi/OpenapiGeneratorExampleApplicationTests.java

OpenAPI Generator Mavenプラグインを使って、Spring Web MVCのエンドポイントを生成する

先ほどのpom.xmlに、OpenAPI GeneratorのMavenプラグインを追加します。

         <plugin>
                <groupId>org.openapitools</groupId>
                <artifactId>openapi-generator-maven-plugin</artifactId>
                <version>6.2.1</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                        <configuration>
                            <inputSpec>${project.basedir}/openapi-definition/openapi.yaml</inputSpec>
                            <generatorName>spring</generatorName>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

バージョンは、ドキュメントに記載されているものではなくて、ちゃんとリリースバージョンを確認した方が良さそうです。

inputSpecにOpenAPIの定義ファイルへのパスを指定し、generatorNameにはspringを選択します。

これは、Generatorの名前ですね。

Generators List

この状態で、コンパイルすると

$ mvn compile

こんな感じで処理が動き

[INFO] --- openapi-generator-maven-plugin:6.2.1:generate (default) @ openapi-generator-example ---
[INFO] Generating with dryRun=false
[INFO] Output directory (/path/to/openapi-generator-example/target/generated-sources/openapi) does not exist, or is inaccessible. No file (.openapi-generator-ignore) will be evaluated.
[INFO] OpenAPI Generator: spring (server)
[INFO] Generator 'spring' is considered stable.
[INFO] ----------------------------------
[INFO] Environment variable JAVA_POST_PROCESS_FILE not defined so the Java code may not be properly formatted. To define it, try 'export JAVA_POST_PROCESS_FILE="/usr/local/bin/clang-format -i"' (Linux/Mac)
[INFO] NOTE: To enable file post-processing, 'enablePostProcessFile' must be set to `true` (--enable-post-process-file for CLI).
[INFO] Processing operation listBooks
[INFO] Processing operation createBook
[INFO] Processing operation getBook
[INFO] Processing operation deleteBook
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/model/BookRequest.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/model/BookResponse.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApiController.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/pom.xml
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/README.md
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/OpenApiGeneratorApplication.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/test/java/org/openapitools/OpenApiGeneratorApplicationTests.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/RFC3339DateFormat.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/resources/application.properties
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/configuration/HomeController.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/resources/openapi.yaml
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/configuration/SpringDocConfiguration.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/ApiUtil.java
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/.openapi-generator-ignore
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/.openapi-generator/VERSION
[INFO] writing file /path/to/openapi-generator-example/target/generated-sources/openapi/.openapi-generator/FILES
################################################################################
# Thanks for using OpenAPI Generator.                                          #
# Please consider donation to help us maintain this project 🙏                 #
# https://opencollective.com/openapi_generator/donate                          #
################################################################################

これだけのファイルが生成されます。

$ find target/generated-sources/openapi -type f
target/generated-sources/openapi/README.md
target/generated-sources/openapi/.openapi-generator-ignore
target/generated-sources/openapi/pom.xml
target/generated-sources/openapi/.openapi-generator/VERSION
target/generated-sources/openapi/.openapi-generator/openapi.yaml-default.sha256
target/generated-sources/openapi/.openapi-generator/FILES
target/generated-sources/openapi/src/main/resources/application.properties
target/generated-sources/openapi/src/main/resources/openapi.yaml
target/generated-sources/openapi/src/main/java/org/openapitools/model/BookResponse.java
target/generated-sources/openapi/src/main/java/org/openapitools/model/BookRequest.java
target/generated-sources/openapi/src/main/java/org/openapitools/configuration/HomeController.java
target/generated-sources/openapi/src/main/java/org/openapitools/configuration/SpringDocConfiguration.java
target/generated-sources/openapi/src/main/java/org/openapitools/api/ApiUtil.java
target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java
target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApiController.java
target/generated-sources/openapi/src/main/java/org/openapitools/OpenApiGeneratorApplication.java
target/generated-sources/openapi/src/main/java/org/openapitools/RFC3339DateFormat.java
target/generated-sources/openapi/src/test/java/org/openapitools/OpenApiGeneratorApplicationTests.java

どうやらMavenプロジェクトが作成されるようですね。targetの配下に…。

ちなみに、コンパイルにも失敗します。

[INFO] --- maven-compiler-plugin:3.10.1:compile (default-compile) @ openapi-generator-example ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 12 source files to /path/to/openapi-generator-example/target/classes
[INFO] -------------------------------------------------------------
[ERROR] COMPILATION ERROR :
[INFO] -------------------------------------------------------------
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java:[10,37] パッケージio.swagger.v3.oas.annotationsは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java:[11,37] パッケージio.swagger.v3.oas.annotationsは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java:[12,37] パッケージio.swagger.v3.oas.annotationsは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java:[13,43] パッケージio.swagger.v3.oas.annotations.mediaは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java:[14,43] パッケージio.swagger.v3.oas.annotations.mediaは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java:[15,47] パッケージio.swagger.v3.oas.annotations.responsesは存在しません

〜省略〜

[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/model/BookRequest.java:[9,41] パッケージorg.openapitools.jackson.nullableは存在しません

〜省略〜

[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java:[27,1] パッケージjavax.validation.constraintsは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/model/BookResponse.java:[12,1] パッケージjavax.validation.constraintsは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/model/BookRequest.java:[12,1] パッケージjavax.validation.constraintsは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/OpenApiGeneratorApplication.java:[4,41] パッケージorg.openapitools.jackson.nullableは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApiController.java:[23,24] パッケージjavax.validationは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApiController.java:[28,24] パッケージjavax.annotationは存在しません
[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApiController.java:[30,2] シンボルを見つけられません

〜省略〜

[ERROR] /path/to/openapi-generator-example/target/generated-sources/openapi/src/main/java/org/openapitools/configuration/SpringDocConfiguration.java:[17,5] シンボルを見つけられません
  シンボル:   クラス OpenAPI
  場所: クラス org.openapitools.configuration.SpringDocConfiguration

〜省略〜

理由はいくつかあって、Swagger v3のパッケージがない、OpenAPI Jackson Nullableのパッケージがない、javax.〜パッケージがない、
SpringDocのパッケージがない、などです。

とりあえず、生成されたものを抜粋でいくつか見てみましょう。

target/generated-sources/openapi/pom.xml

<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/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-spring</artifactId>
    <packaging>jar</packaging>
    <name>openapi-spring</name>
    <version>0.0.1</version>
    <properties>
        <java.version>1.8</java.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <springdoc.version>1.6.8</springdoc.version>
        <swagger-ui.version>4.10.3</swagger-ui.version>
    </properties>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <build>
        <sourceDirectory>src/main/java</sourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-commons</artifactId>
        </dependency>
          <!--SpringDoc dependencies -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
        <!-- @Nullable annotation -->
        <dependency>
            <groupId>com.google.code.findbugs</groupId>
            <artifactId>jsr305</artifactId>
            <version>3.0.2</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-yaml</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
        </dependency>
        <dependency>
            <groupId>org.openapitools</groupId>
            <artifactId>jackson-databind-nullable</artifactId>
            <version>0.2.2</version>
        </dependency>
        <!-- Bean Validation API support -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

target/generated-sources/openapi/src/main/resources/application.properties

server.port=8080
spring.jackson.date-format=org.openapitools.RFC3339DateFormat
spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false

target/generated-sources/openapi/src/main/java/org/openapitools/model/BookResponse.java

package org.openapitools.model;

import java.net.URI;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import java.util.ArrayList;
import java.util.List;
import org.openapitools.jackson.nullable.JsonNullable;
import java.time.OffsetDateTime;
import javax.validation.Valid;
import javax.validation.constraints.*;
import io.swagger.v3.oas.annotations.media.Schema;


import java.util.*;
import javax.annotation.Generated;

/**
 * BookResponse
 */

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-19T23:19:06.544145948+09:00[Asia/Tokyo]")
public class BookResponse {

  @JsonProperty("isbn")
  private String isbn;

  @JsonProperty("title")
  private String title;

  @JsonProperty("price")
  private Integer price;

  @JsonProperty("tags")
  @Valid
  private List<String> tags = null;

  〜省略〜
}

target/generated-sources/openapi/src/main/java/org/openapitools/model/BookRequest.java

package org.openapitools.model;

import java.net.URI;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import java.util.ArrayList;
import java.util.List;
import org.openapitools.jackson.nullable.JsonNullable;
import java.time.OffsetDateTime;
import javax.validation.Valid;
import javax.validation.constraints.*;
import io.swagger.v3.oas.annotations.media.Schema;


import java.util.*;
import javax.annotation.Generated;

/**
 * BookRequest
 */

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-19T23:19:06.544145948+09:00[Asia/Tokyo]")
public class BookRequest {

  @JsonProperty("isbn")
  private String isbn;

  @JsonProperty("title")
  private String title;

  @JsonProperty("price")
  private Integer price;

  @JsonProperty("tags")
  @Valid
  private List<String> tags = null;

  〜省略〜

target/generated-sources/openapi/src/main/java/org/openapitools/configuration/HomeController.java

package org.openapitools.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.GetMapping;

/**
 * Home redirection to OpenAPI api documentation
 */
@Controller
public class HomeController {

    @RequestMapping("/")
    public String index() {
        return "redirect:swagger-ui.html";
    }

}

target/generated-sources/openapi/src/main/java/org/openapitools/configuration/SpringDocConfiguration.java

package org.openapitools.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.security.SecurityScheme;

@Configuration
public class SpringDocConfiguration {

    @Bean
    OpenAPI apiInfo() {
        return new OpenAPI()
                .info(
                        new Info()
                                .title("OpenAPI Definition Example")
                                .description("No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)")
                                .version("0.0.1")
                )
        ;
    }
}

target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApi.java

/**
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (6.2.1).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */
package org.openapitools.api;

import org.openapitools.model.BookRequest;
import org.openapitools.model.BookResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.multipart.MultipartFile;

import javax.validation.Valid;
import javax.validation.constraints.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Generated;

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-19T23:19:06.544145948+09:00[Asia/Tokyo]")
@Validated
@Tag(name = "books", description = "Book operations")
public interface BooksApi {

    default Optional<NativeWebRequest> getRequest() {
        return Optional.empty();
    }

    /**
     * POST /books : Create book
     *
     * @param bookRequest  (optional)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "createBook",
        summary = "Create book",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = Object.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.POST,
        value = "/books",
        produces = { "application/json" },
        consumes = { "application/json" }
    )
    default ResponseEntity<Object> createBook(
        @Parameter(name = "BookRequest", description = "") @Valid @RequestBody(required = false) BookRequest bookRequest
    ) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }


    /**
     * DELETE /books/{isbn} : Delete book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "deleteBook",
        summary = "Delete book by isbn",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = Object.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.DELETE,
        value = "/books/{isbn}",
        produces = { "application/json" }
    )
    default ResponseEntity<Object> deleteBook(
        @Parameter(name = "isbn", description = "", required = true) @PathVariable("isbn") String isbn
    ) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }


    /**
     * GET /books/{isbn} : Get book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "getBook",
        summary = "Get book by isbn",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = BookResponse.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.GET,
        value = "/books/{isbn}",
        produces = { "application/json" }
    )
    default ResponseEntity<BookResponse> getBook(
        @Parameter(name = "isbn", description = "", required = true) @PathVariable("isbn") String isbn
    ) {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"price\" : 4400, \"isbn\" : \"978-4621303252\", \"title\" : \"Effective Java 第3版\", \"tags\" : [ \"java\", \"programming\" ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }


    /**
     * GET /books : List books
     *
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "listBooks",
        summary = "List books",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = BookResponse.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.GET,
        value = "/books",
        produces = { "application/json" }
    )
    default ResponseEntity<List<BookResponse>> listBooks(

    ) {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"price\" : 4400, \"isbn\" : \"978-4621303252\", \"title\" : \"Effective Java 第3版\", \"tags\" : [ \"java\", \"programming\" ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

}

target/generated-sources/openapi/src/main/java/org/openapitools/api/BooksApiController.java

package org.openapitools.api;

import org.openapitools.model.BookRequest;
import org.openapitools.model.BookResponse;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.context.request.NativeWebRequest;

import javax.validation.constraints.*;
import javax.validation.Valid;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Generated;

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-19T23:19:06.544145948+09:00[Asia/Tokyo]")
@Controller
@RequestMapping("${openapi.openAPIDefinitionExample.base-path:}")
public class BooksApiController implements BooksApi {

    private final NativeWebRequest request;

    @Autowired
    public BooksApiController(NativeWebRequest request) {
        this.request = request;
    }

    @Override
    public Optional<NativeWebRequest> getRequest() {
        return Optional.ofNullable(request);
    }

}

target/generated-sources/openapi/src/main/java/org/openapitools/OpenApiGeneratorApplication.java

package org.openapitools;

import com.fasterxml.jackson.databind.Module;
import org.openapitools.jackson.nullable.JsonNullableModule;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan(basePackages = {"org.openapitools", "org.openapitools.api" , "org.openapitools.configuration"})
public class OpenApiGeneratorApplication {

    public static void main(String[] args) {
        SpringApplication.run(OpenApiGeneratorApplication.class, args);
    }

    @Bean
    public Module jsonNullableModule() {
        return new JsonNullableModule();
    }

}

target/generated-sources/openapi/src/main/java/org/openapitools/RFC3339DateFormat.java

package org.openapitools;

import com.fasterxml.jackson.databind.util.StdDateFormat;

import java.text.DateFormat;
import java.text.FieldPosition;
import java.text.ParsePosition;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;

public class RFC3339DateFormat extends DateFormat {
  private static final long serialVersionUID = 1L;
  private static final TimeZone TIMEZONE_Z = TimeZone.getTimeZone("UTC");

  private final StdDateFormat fmt = new StdDateFormat()
          .withTimeZone(TIMEZONE_Z)
          .withColonInTimeZone(true);

  public RFC3339DateFormat() {
    this.calendar = new GregorianCalendar();
  }

  @Override
  public Date parse(String source, ParsePosition pos) {
    return fmt.parse(source, pos);
  }

  @Override
  public StringBuffer format(Date date, StringBuffer toAppendTo, FieldPosition fieldPosition) {
    return fmt.format(date, toAppendTo, fieldPosition);
  }

  @Override
  public Object clone() {
    return this;
  }
}

Controllerやmainクラスまで生成されるんですね…。

この後、生成されたControllerに対して実装していくことになるようですが、そうなるともう1度自動生成すると実装した内容が失われることに
なりそうです。

このあたりは、interfaceOnlyをtrueとするかdelegatePatternをtrueとして対処していくようです。

OpenAPI Generatorを使ったコードの自動生成とインタフェースの守り方

springdoc-openapi

ところで、自動生成されたソースコードの中に、ちょっと不思議なものが入っていました。

target/generated-sources/openapi/src/main/java/org/openapitools/configuration/HomeController.java

package org.openapitools.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.GetMapping;

/**
 * Home redirection to OpenAPI api documentation
 */
@Controller
public class HomeController {

    @RequestMapping("/")
    public String index() {
        return "redirect:swagger-ui.html";
    }

}

target/generated-sources/openapi/src/main/java/org/openapitools/configuration/SpringDocConfiguration.java

package org.openapitools.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.security.SecurityScheme;

@Configuration
public class SpringDocConfiguration {

    @Bean
    OpenAPI apiInfo() {
        return new OpenAPI()
                .info(
                        new Info()
                                .title("OpenAPI Definition Example")
                                .description("No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)")
                                .version("0.0.1")
                )
        ;
    }
}

これは、springdoc-openapiによるSwagger UIやOpenAPI定義の公開に使われる設定のようです。

OpenAPI GeneratorのdocumentationProviderオプションのデフォルト値がspringdocなので、springdoc-openapiが使われるようです。

springdoc-openapiのサイトはこちら。

OpenAPI 3 Library for spring-boot

GitHub - springdoc/springdoc-openapi: Library for OpenAPI 3 with spring-boot

依存関係を追加する

ここまでで、自動生成されたソースコードをコンパイルするには、依存関係の追加が必要そうなことがわかります。

OpenAPI Generator Mavenプラグインのページでは、swagger-annotationsを追加する必要があるようなことが書かれていますが、

Plugins

Spring向けのGeneratorを使う場合は、以下を加えておけばよさそうです。

     <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-ui</artifactId>
            <version>1.6.14</version>
        </dependency>
        <dependency>
            <groupId>org.openapitools</groupId>
            <artifactId>jackson-databind-nullable</artifactId>
            <version>0.2.4</version>
        </dependency>

springdoc-openapiとOpenAPI Jackson Nullableですね。

Documentation for the spring Generator

これは、documentationProviderとopenApiNullableの指定で決まりますが、今回はそれぞれのデフォルト値に従います。

interfaceOnlyかdelegatePatternか

interfaceOnlyをtrueとするかdelegatePatternをtrueとすることで、自動生成されたソースコードの拡張がしやすくなるという話を
書きました。

Generation Gap Patternを利用することになるようです。

それぞれどうなるか、まずは出力結果を確認してみましょう。

生成するパッケージ名などは先に指定しておきましょう。また、useSpringBoot3をtrueとすることで生成されるソースコードがjavax.〜
パッケージへの依存ではなくjakarta.〜パッケージへの依存へと変更になります。

                     <configuration>
                            <inputSpec>${project.basedir}/openapi-definition/openapi.yaml</inputSpec>
                            <generatorName>spring</generatorName>
                            <configOptions>
                                <sourceFolder>src/main/java</sourceFolder>
                                <basePackage>org.littlewings.spring.openapi.generated</basePackage>
                                <apiPackage>org.littlewings.spring.openapi.generated.api</apiPackage>
                                <modelPackage>org.littlewings.spring.openapi.generated.model</modelPackage>
                                <configPackage>org.littlewings.spring.openapi.generated.configuration</configPackage>
                                <useSpringBoot3>true</useSpringBoot3>
                            </configOptions>
                        </configuration>

では、interfaceOnlyをtrueとして

                     <configuration>
                            <inputSpec>${project.basedir}/openapi-definition/openapi.yaml</inputSpec>
                            <generatorName>spring</generatorName>
                            <configOptions>
                                <sourceFolder>src/main/java</sourceFolder>
                                <basePackage>org.littlewings.spring.openapi.generated</basePackage>
                                <apiPackage>org.littlewings.spring.openapi.generated.api</apiPackage>
                                <modelPackage>org.littlewings.spring.openapi.generated.model</modelPackage>
                                <configPackage>org.littlewings.spring.openapi.generated.configuration</configPackage>
                                <useSpringBoot3>true</useSpringBoot3>
                                <interfaceOnly>true</interfaceOnly>
                            </configOptions>
                        </configuration>

前の生成結果を削除しつつ、コンパイル。

$ mvn clean compile

ちなみに、依存関係を調整したのでこの時点で自動生成されたソースコードのコンパイルエラーは出なくなります。

確認。

$ find target/generated-sources/openapi -type f
target/generated-sources/openapi/README.md
target/generated-sources/openapi/.openapi-generator-ignore
target/generated-sources/openapi/pom.xml
target/generated-sources/openapi/.openapi-generator/VERSION
target/generated-sources/openapi/.openapi-generator/openapi.yaml-default.sha256
target/generated-sources/openapi/.openapi-generator/FILES
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/BookResponse.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/BookRequest.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/ApiUtil.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApi.java

生成されるソースコードが、一気に減りました。インターフェースと入出力のみという感じですね。

mainメソッドを持ったクラスやapplication.propertiesもなくなりました。

インターフェースの定義だけ見ておきましょう。

target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApi.java

/**
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (6.2.1).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */
package org.littlewings.spring.openapi.generated.api;

import org.littlewings.spring.openapi.generated.model.BookRequest;
import org.littlewings.spring.openapi.generated.model.BookResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.multipart.MultipartFile;

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import jakarta.annotation.Generated;

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-20T23:22:17.423913445+09:00[Asia/Tokyo]")
@Validated
@Tag(name = "books", description = "Book operations")
public interface BooksApi {

    default Optional<NativeWebRequest> getRequest() {
        return Optional.empty();
    }

    /**
     * POST /books : Create book
     *
     * @param bookRequest  (optional)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "createBook",
        summary = "Create book",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = Object.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.POST,
        value = "/books",
        produces = { "application/json" },
        consumes = { "application/json" }
    )
    default ResponseEntity<Object> createBook(
        @Parameter(name = "BookRequest", description = "") @Valid @RequestBody(required = false) BookRequest bookRequest
    ) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }


    /**
     * DELETE /books/{isbn} : Delete book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "deleteBook",
        summary = "Delete book by isbn",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = Object.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.DELETE,
        value = "/books/{isbn}",
        produces = { "application/json" }
    )
    default ResponseEntity<Object> deleteBook(
        @Parameter(name = "isbn", description = "", required = true) @PathVariable("isbn") String isbn
    ) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }


    /**
     * GET /books/{isbn} : Get book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "getBook",
        summary = "Get book by isbn",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = BookResponse.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.GET,
        value = "/books/{isbn}",
        produces = { "application/json" }
    )
    default ResponseEntity<BookResponse> getBook(
        @Parameter(name = "isbn", description = "", required = true) @PathVariable("isbn") String isbn
    ) {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"price\" : 4400, \"isbn\" : \"978-4621303252\", \"title\" : \"Effective Java 第3版\", \"tags\" : [ \"java\", \"programming\" ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }


    /**
     * GET /books : List books
     *
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "listBooks",
        summary = "List books",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = BookResponse.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.GET,
        value = "/books",
        produces = { "application/json" }
    )
    default ResponseEntity<List<BookResponse>> listBooks(

    ) {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"price\" : 4400, \"isbn\" : \"978-4621303252\", \"title\" : \"Effective Java 第3版\", \"tags\" : [ \"java\", \"programming\" ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

}

Controllerもなくなったので、利用者は素直にこのインターフェースを実装する感じですね。

次は、delegatePatternをtrueにしてみましょう。

                     <configuration>
                            <inputSpec>${project.basedir}/openapi-definition/openapi.yaml</inputSpec>
                            <generatorName>spring</generatorName>
                            <configOptions>
                                <sourceFolder>src/main/java</sourceFolder>
                                <basePackage>org.littlewings.spring.openapi.generated</basePackage>
                                <apiPackage>org.littlewings.spring.openapi.generated.api</apiPackage>
                                <modelPackage>org.littlewings.spring.openapi.generated.model</modelPackage>
                                <configPackage>org.littlewings.spring.openapi.generated.configuration</configPackage>
                                <useSpringBoot3>true</useSpringBoot3>
                                <delegatePattern>true</delegatePattern>
                            </configOptions>
                        </configuration>

先ほどと同じように、前の生成結果を削除しつつコンパイル。

$ find target/generated-sources/openapi -type f
target/generated-sources/openapi/README.md
target/generated-sources/openapi/.openapi-generator-ignore
target/generated-sources/openapi/pom.xml
target/generated-sources/openapi/.openapi-generator/VERSION
target/generated-sources/openapi/.openapi-generator/openapi.yaml-default.sha256
target/generated-sources/openapi/.openapi-generator/FILES
target/generated-sources/openapi/src/main/resources/application.properties
target/generated-sources/openapi/src/main/resources/openapi.yaml
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/BookResponse.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/BookRequest.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/configuration/HomeController.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/configuration/SpringDocConfiguration.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApiDelegate.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/ApiUtil.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApi.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApiController.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/OpenApiGeneratorApplication.java
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/RFC3339DateFormat.java
target/generated-sources/openapi/src/test/java/org/littlewings/spring/openapi/generated/OpenApiGeneratorApplicationTests.java

Delegateというファイルが増えました。

中身はこんな感じで、インターフェース定義のようです。

target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApiDelegate.java

package org.littlewings.spring.openapi.generated.api;

import org.littlewings.spring.openapi.generated.model.BookRequest;
import org.littlewings.spring.openapi.generated.model.BookResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import jakarta.annotation.Generated;

/**
 * A delegate to be called by the {@link BooksApiController}}.
 * Implement this interface with a {@link org.springframework.stereotype.Service} annotated class.
 */
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-20T23:32:23.733004280+09:00[Asia/Tokyo]")
public interface BooksApiDelegate {

    default Optional<NativeWebRequest> getRequest() {
        return Optional.empty();
    }

    /**
     * POST /books : Create book
     *
     * @param bookRequest  (optional)
     * @return OK (status code 200)
     * @see BooksApi#createBook
     */
    default ResponseEntity<Object> createBook(BookRequest bookRequest) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

    /**
     * DELETE /books/{isbn} : Delete book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     * @see BooksApi#deleteBook
     */
    default ResponseEntity<Object> deleteBook(String isbn) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

    /**
     * GET /books/{isbn} : Get book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     * @see BooksApi#getBook
     */
    default ResponseEntity<BookResponse> getBook(String isbn) {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"price\" : 4400, \"isbn\" : \"978-4621303252\", \"title\" : \"Effective Java 第3版\", \"tags\" : [ \"java\", \"programming\" ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

    /**
     * GET /books : List books
     *
     * @return OK (status code 200)
     * @see BooksApi#listBooks
     */
    default ResponseEntity<List<BookResponse>> listBooks() {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"price\" : 4400, \"isbn\" : \"978-4621303252\", \"title\" : \"Effective Java 第3版\", \"tags\" : [ \"java\", \"programming\" ] }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

}

Controllerの方を見ると、Delegateを扱うだけになっています。

target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApiController.java

package org.littlewings.spring.openapi.generated.api;

import org.littlewings.spring.openapi.generated.model.BookRequest;
import org.littlewings.spring.openapi.generated.model.BookResponse;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import jakarta.annotation.Generated;

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-20T23:32:23.733004280+09:00[Asia/Tokyo]")
@Controller
@RequestMapping("${openapi.openAPIDefinitionExample.base-path:}")
public class BooksApiController implements BooksApi {

    private final BooksApiDelegate delegate;

    public BooksApiController(@Autowired(required = false) BooksApiDelegate delegate) {
        this.delegate = Optional.ofNullable(delegate).orElse(new BooksApiDelegate() {});
    }

    @Override
    public BooksApiDelegate getDelegate() {
        return delegate;
    }

}

インターフェース定義はどうなっているかというと、デフォルト実装はDelegateを使うようになっています。

target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/BooksApi.java

/**
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (6.2.1).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */
package org.littlewings.spring.openapi.generated.api;

import org.littlewings.spring.openapi.generated.model.BookRequest;
import org.littlewings.spring.openapi.generated.model.BookResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Map;
import jakarta.annotation.Generated;

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-12-20T23:32:23.733004280+09:00[Asia/Tokyo]")
@Validated
@Tag(name = "books", description = "Book operations")
public interface BooksApi {

    default BooksApiDelegate getDelegate() {
        return new BooksApiDelegate() {};
    }

    /**
     * POST /books : Create book
     *
     * @param bookRequest  (optional)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "createBook",
        summary = "Create book",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = Object.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.POST,
        value = "/books",
        produces = { "application/json" },
        consumes = { "application/json" }
    )
    default ResponseEntity<Object> createBook(
        @Parameter(name = "BookRequest", description = "") @Valid @RequestBody(required = false) BookRequest bookRequest
    ) {
        return getDelegate().createBook(bookRequest);
    }


    /**
     * DELETE /books/{isbn} : Delete book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "deleteBook",
        summary = "Delete book by isbn",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = Object.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.DELETE,
        value = "/books/{isbn}",
        produces = { "application/json" }
    )
    default ResponseEntity<Object> deleteBook(
        @Parameter(name = "isbn", description = "", required = true) @PathVariable("isbn") String isbn
    ) {
        return getDelegate().deleteBook(isbn);
    }


    /**
     * GET /books/{isbn} : Get book by isbn
     *
     * @param isbn  (required)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "getBook",
        summary = "Get book by isbn",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = BookResponse.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.GET,
        value = "/books/{isbn}",
        produces = { "application/json" }
    )
    default ResponseEntity<BookResponse> getBook(
        @Parameter(name = "isbn", description = "", required = true) @PathVariable("isbn") String isbn
    ) {
        return getDelegate().getBook(isbn);
    }


    /**
     * GET /books : List books
     *
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "listBooks",
        summary = "List books",
        tags = { "book" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = {
                @Content(mediaType = "application/json", schema = @Schema(implementation = BookResponse.class))
            })
        }
    )
    @RequestMapping(
        method = RequestMethod.GET,
        value = "/books",
        produces = { "application/json" }
    )
    default ResponseEntity<List<BookResponse>> listBooks(

    ) {
        return getDelegate().listBooks();
    }

}

この場合、利用者はDelegateインターフェースの実装を作成することになるみたいですね。

それぞれ一長一短はありそうです。

OpenAPI Generatorを使ったコードの自動生成とインタフェースの守り方

ここまでで、1度clean。

$ mvn clean

interfaceOnlyで作ってみる

それで、今回はどうするかというとinterfaceOnlyをtrueにして使うことにします。理由はあまりなく、とりあえずインターフェースを実装して
済ませたかったからです…。

                     <configuration>
                            <inputSpec>${project.basedir}/openapi-definition/openapi.yaml</inputSpec>
                            <generatorName>spring</generatorName>
                            <configOptions>
                                <sourceFolder>src/main/java</sourceFolder>
                                <basePackage>org.littlewings.spring.openapi.generated</basePackage>
                                <apiPackage>org.littlewings.spring.openapi.generated.api</apiPackage>
                                <modelPackage>org.littlewings.spring.openapi.generated.model</modelPackage>
                                <configPackage>org.littlewings.spring.openapi.generated.configuration</configPackage>
                                <useSpringBoot3>true</useSpringBoot3>
                                <interfaceOnly>true</interfaceOnly>
                            </configOptions>
                        </configuration>

こちらを利用するクラスを作成。target/generated-sources/openapiディレクトリ配下に生成されたインターフェースを、そのまま実装します。

src/main/java/org/littlewings/spring/openapi/BooksController.java

package org.littlewings.spring.openapi;

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

import jakarta.servlet.http.HttpServletRequest;
import org.littlewings.spring.openapi.generated.api.BooksApi;
import org.littlewings.spring.openapi.generated.model.BookRequest;
import org.littlewings.spring.openapi.generated.model.BookResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.util.UriComponentsBuilder;

@RestController
public class BooksController implements BooksApi {
    ConcurrentMap<String, Book> store = new ConcurrentHashMap<>();

    NativeWebRequest request;

    public BooksController(NativeWebRequest request) {
        this.request = request;
    }

    @Override
    public Optional<NativeWebRequest> getRequest() {
        return Optional.of(request);
    }

    @Override
    public ResponseEntity<Object> createBook(BookRequest bookRequest) {
        store.put(bookRequest.getIsbn(), toBook(bookRequest));

        UriComponentsBuilder uriComponentsBuilder =
                UriComponentsBuilder
                        .fromHttpRequest(
                                new ServletServerHttpRequest(getRequest().get().getNativeRequest(HttpServletRequest.class))
                        );
        return ResponseEntity
                .created(uriComponentsBuilder.path("/{isbn}").build(bookRequest.getIsbn()))
                .build();
    }

    @Override
    public ResponseEntity<Object> deleteBook(String isbn) {
        store.remove(isbn);
        return ResponseEntity.noContent().build();
    }

    @Override
    public ResponseEntity<BookResponse> getBook(String isbn) {
        Book book = store.get(isbn);

        if (book != null) {
            return ResponseEntity.ok(fromBook(book));
        } else {
            return ResponseEntity.notFound().build();
        }
    }

    @Override
    public ResponseEntity<List<BookResponse>> listBooks() {
        return ResponseEntity.ok(
                store.values().stream().map(this::fromBook).toList()
        );
    }

    public Book toBook(BookRequest bookRequest) {
        Book book = new Book();
        book.setIsbn(bookRequest.getIsbn());
        book.setTitle(bookRequest.getTitle());
        book.setPrice(bookRequest.getPrice());
        book.setTags(bookRequest.getTags());
        return book;
    }

    public BookResponse fromBook(Book book) {
        BookResponse response = new BookResponse();
        response.setIsbn(book.getIsbn());
        response.setTitle(book.getTitle());
        response.setPrice(book.getPrice());
        response.setTags(book.getTags());
        return response;
    }
}

先に登場してしまいましたが、リクエストで受けた書籍データはエンティティ的なクラスに変換して保持しておくことにします。

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

package org.littlewings.spring.openapi;

import java.util.List;

public class Book {
    String isbn;
    String title;
    int price;
    List<String> tags;

    // getter/setterは省略
}

mainメソッドを持ったクラス。

src/main/java/org/littlewings/spring/openapi/App.java

package org.littlewings.spring.openapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

準備ができたので、アプリケーションを起動。

$ mvn spring-boot:run

確認します。

データの登録。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books -d '{"isbn": "978-4621303252", "title": "Effective Java 第3版", "price": 4400, "tags": ["java", "programming"]}'
HTTP/1.1 201
Location: http://localhost:8080/books/978-4621303252
Content-Length: 0
Date: Tue, 20 Dec 2022 15:00:53 GMT


$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books -d '{"isbn": "978-1782169970", "title": "Infinispan Data Grid Platform Definitive Guide", "price": 5242, "tags": ["in-memory-data-grid"]}'
HTTP/1.1 201
Location: http://localhost:8080/books/978-1782169970
Content-Length: 0
Date: Tue, 20 Dec 2022 15:01:08 GMT


$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books -d '{"isbn": "978-4798161488", "title": "MySQL徹底入門 第4版 MySQL 8.0対応", "price": 4180, "tags": ["mysql", "database"]}'
HTTP/1.1 201
Location: http://localhost:8080/books/978-4798161488
Content-Length: 0
Date: Tue, 20 Dec 2022 15:01:16 GMT


$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books -d '{"isbn": "978-1098116743", "title": "Terraform: Up & Running; Writing Infrastructure As Code", "price": 7404, "tags": ["terraform", "infra-structure-as-code"]}'
HTTP/1.1 201
Location: http://localhost:8080/books/978-1098116743
Content-Length: 0
Date: Tue, 20 Dec 2022 15:01:23 GMT

複数件のデータの取得。

$ curl -i localhost:8080/books
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 20 Dec 2022 15:02:43 GMT

[{"isbn":"978-1782169970","title":"Infinispan Data Grid Platform Definitive Guide","price":5242,"tags":["in-memory-data-grid"]},{"isbn":"978-1098116743","title":"Terraform: Up & Running; Writing Infrastructure As Code","price":7404,"tags":["terraform","infra-structure-as-code"]},{"isbn":"978-4798161488","title":"MySQL徹底入門 第4版 MySQL 8.0対応","price":4180,"tags":["mysql","database"]},{"isbn":"978-4621303252","title":"Effective Java 第3版","price":4400,"tags":["java","programming"]}]


$ curl -s localhost:8080/books | jq
[
  {
    "isbn": "978-1782169970",
    "title": "Infinispan Data Grid Platform Definitive Guide",
    "price": 5242,
    "tags": [
      "in-memory-data-grid"
    ]
  },
  {
    "isbn": "978-1098116743",
    "title": "Terraform: Up & Running; Writing Infrastructure As Code",
    "price": 7404,
    "tags": [
      "terraform",
      "infra-structure-as-code"
    ]
  },
  {
    "isbn": "978-4798161488",
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
    "price": 4180,
    "tags": [
      "mysql",
      "database"
    ]
  },
  {
    "isbn": "978-4621303252",
    "title": "Effective Java 第3版",
    "price": 4400,
    "tags": [
      "java",
      "programming"
    ]
  }
]

単一データの取得。

$ curl -i localhost:8080/books/978-4798161488
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 20 Dec 2022 15:03:27 GMT

{"isbn":"978-4798161488","title":"MySQL徹底入門 第4版 MySQL 8.0対応","price":4180,"tags":["mysql","database"]}

データの削除。

$ curl -i -XDELETE localhost:8080/books/978-4798161488
HTTP/1.1 204
Date: Tue, 20 Dec 2022 15:03:50 GMT

削除確認。

$ curl -i localhost:8080/books/978-4798161488
HTTP/1.1 404
Content-Length: 0
Date: Tue, 20 Dec 2022 15:04:02 GMT

こんなところでしょうか。

最後に、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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-parent</artifactId>
                <version>3.0.0</version>
                <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>org.littlewings</groupId>
        <artifactId>openapi-generator-example</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>openapi-generator-example</name>
        <description>Demo project for Spring Boot</description>
        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-web</artifactId>
                </dependency>
                <dependency>
                        <groupId>org.springdoc</groupId>
                        <artifactId>springdoc-openapi-ui</artifactId>
                        <version>1.6.14</version>
                </dependency>
                <dependency>
                        <groupId>org.openapitools</groupId>
                        <artifactId>jackson-databind-nullable</artifactId>
                        <version>0.2.4</version>
                </dependency>

                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                        <plugin>
                                <groupId>org.openapitools</groupId>
                                <artifactId>openapi-generator-maven-plugin</artifactId>
                                <version>6.2.1</version>
                                <executions>
                                        <execution>
                                                <goals>
                                                        <goal>generate</goal>
                                                </goals>
                                                <configuration>
                                                        <inputSpec>${project.basedir}/openapi-definition/openapi.yaml</inputSpec>
                                                        <generatorName>spring</generatorName>
                                                        <configOptions>
                                                                <sourceFolder>src/main/java</sourceFolder>
                                                                <basePackage>org.littlewings.spring.openapi.generated</basePackage>
                                                                <apiPackage>org.littlewings.spring.openapi.generated.api</apiPackage>
                                                                <modelPackage>org.littlewings.spring.openapi.generated.model</modelPackage>
                                                                <configPackage>org.littlewings.spring.openapi.generated.configuration</configPackage>
                                                                <useSpringBoot3>true</useSpringBoot3>
                                                                <interfaceOnly>true</interfaceOnly>
                                                        </configOptions>
                                                </configuration>
                                        </execution>
                                </executions>
                        </plugin>
                </plugins>
        </build>

</project>

まとめ

OpenAPI Generatorを使って、Spring Web MVCのエンドポイントを作成してみました。

そもそもOpenAPI Generatorの使い方がよくわからなかったので雰囲気を掴むのにだいぶ苦労しましたが、とりあえず生成したソースコードを
使ってアプリケーションを作るところまでできたので、良しとしましょうか…。