これは、なにをしたくて書いたもの?
OpenAPI Generatorを使って、OpenAPIの定義ファイルからSpring Web MVCのエンドポイントを生成してみようかな、ということで。
OpenAPI Generator
OpenAPI Generatorは、OpenAPIの定義ファイルからクライアントやサーバー、ドキュメントを生成するツールです。
平静を保ち、コードを生成せよ 〜 OpenAPI Generator誕生の背景と軌跡 〜 / gunmaweb34 - Speaker Deck
CLIやMavenプラグイン、Gradleプラグイン、npmを使ったCLI、SaaSなどがあり、様々な方法で生成することができます。
設定まわり。
ジェネレーターにも種類があり、クライアント、サーバー、ドキュメント、スキーマ定義、CONFIGと様々な種類があります。
今回は、サーバーのSpring Web MVCを対象にします。
設定は、こちらを参照ですね。
Documentation for the spring Generator
ただ、ソースコードでの設定項目も合わせて見た方がいいような気もしますね…。
こちらのページの内容は、バージョンが最新とは限らないようです。
生成の際に使われるテンプレートはMustacheで、こちらに配置されているようです。
Springの時に使われるテンプレートは、こちら。
テンプレートについては、カスタマイズや自分で用意したりもできるようですが、今回は扱いません。
では、試してみましょう。
環境
今回の環境は、こちら。
$ 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の名前ですね。
この状態で、コンパイルすると
$ 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
を追加する必要があるようなことが書かれていますが、
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の使い方がよくわからなかったので雰囲気を掴むのにだいぶ苦労しましたが、とりあえず生成したソースコードを
使ってアプリケーションを作るところまでできたので、良しとしましょうか…。