これは、なにをしたくて書いたもの?
OpenAPIを使ってREST APIを定義した時に、required
や文字列長、範囲についての記述をしておくと、OpenAPI Generatorを使って
自動生成した時にある程度バリデーションの定義も生成してくれます。
OpenAPI Specification v3.0.3 / Specification / Schema / Data Types
OpenAPI Specification v3.0.3 / Specification / Schema / Schema Object / Properties
とはいえ、そのままでは生成されるバリデーションには限界があるので、自分でバリデーション定義を追加したい場合は
どうしたらよいのかな?と思って調べてみました。
方法は?
調べてみると、ざっくり次の2つの方法がありそうです。
OpenAPIの拡張仕様とOpenAPI Generatorのテンプレートの拡張を使う方法。
Custom Validation with Swagger Codegen | Baeldung
OpenAPI generator to spring-boot with custom java validations | by Matúš Bartko | Medium
Jakarta Bean Validationのvalidation.xml
とconstraint-mappings
を使う方法。
OpenApi generated JAX/RS Service: Bean Validation
後者は、OpenAPI定義とOpenAPI Generatorというよりは、Bean Validation側にバリデーション定義を寄せてしまおうという感じですね。
Jakarta Bean Validation specification / XML deployment descriptor
OpenAPI定義をちゃんと利用するという意味では、拡張仕様とOpenAPI Generatorのテンプレート拡張の方がアプローチとしては
良さそうなのではと思ったので、こちらを見ていくことにします。
OpenAPIのSpecification Extensions(拡張仕様)
最初に、OpenAPIのSpecification Extensions(拡張仕様)について見てみましょう。
Specification Extensionsというのはx-
の接頭辞で定義されたプロパティのことで、文字通り仕様拡張のために使われます。
While the OpenAPI Specification tries to accommodate most use cases, additional data can be added to extend the specification at certain points.
The extensions properties are implemented as patterned fields that are always prefixed by "x-".
OpenAPI Specification v3.0.3 / Specification Extensions
The extensions may or may not be supported by the available tooling, but those may be extended as well to add requested support (if tools are internal or open-sourced).
OpenAPIに関するツールで使われることが多そうですね。
そして、OpenAPI Generatorではテンプレート内で{{#vendorExtensions.[x-...]}}...{{/vendorExtensins.[x-...]}}
というように参照することが
できます。
実際に、x-
の拡張仕様を扱っているのはSwaggerみたいですけどね。
そして、OpenAPI Generatorのテンプレートは変更することができ、これでOpenAPI Generatorが生成するソースコードをカスタマイズ
することができます。
Using Templates | OpenAPI Generator
自分で作成したテンプレートで、もともと組み込まれているテンプレートを上書きするものみたいですけどね。
Note: You cannot use this approach to create new templates, only override existing ones.
Using Templates / Modifying Templates
テンプレートは、Mustacheで書かれています。
GitHub - samskivert/jmustache: A Java implementation of the Mustache templating language.
今回は、この仕組みを使って自分でMustacheテンプレートを書いて、OpenAPI Generatorが生成するソースコードをカスタマイズして
独自にバリデーションを追加してみようと思います。
お題
OpenAPI定義でデータ型にstring
に、フォーマットにemail
を選択して、Spring向けのOpenAPI Generatorを使ったソースコード生成を
行うと、モデルにJakarta Bean Validationの@Email
が付与されます。
Email (Jakarta EE Platform API)
こちらは残したままで、WHATWGの定義する以下の正規表現でバリデーションする@HtmlInputEmail
というアノテーションを
付与できるようにしたいと思います。
The following JavaScript- and Perl-compatible regular expression is an implementation of the above definition.
/^[a-zA-Z0-9.!#$%&'+\/=?^_`{|}~-]+@a-zA-Z0-9?(?:.a-zA-Z0-9?)$/
環境
今回の環境は、こちら。
$ java --version openjdk 17.0.5 2022-10-18 OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu122.04) OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu122.04, mixed mode, sharing) $ mvn --version Apache Maven 3.8.7 (b89d5959fcde851dcb1c8946a785a163f14e1e29) 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.15.0-58-generic", arch: "amd64", family: "unix"
Spring Bootプロジェクトを作成する
まずは、Spring Bootプロジェクトを作成します。依存関係には、web
とvalidation
を指定。
$ curl -s https://start.spring.io/starter.tgz \ -d bootVersion=3.0.2 \ -d javaVersion=17 \ -d type=maven-project \ -d name=openapi-generator-custom-validation \ -d groupId=org.littlewings \ -d artifactId=openapi-generator-custom-validation \ -d version=0.0.1-SNAPSHOT \ -d packageName=org.littlewings.spring.openapi \ -d dependencies=web,validation \ -d baseDir=openapi-generator-custom-validation | tar zxvf -
プロジェクト内に移動。
$ cd openapi-generator-custom-validation
Mavenの依存関係やプラグイン設定などは、こうなっています。
<properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <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/OpenapiGeneratorCustomValidationApplication.java src/test/java/org/littlewings/spring/openapi/OpenapiGeneratorCustomValidationApplicationTests.java
OpenAPI定義を行う
なにはともあれ、OpenAPI定義がないと始まりません。
最初は以下の定義にしたいと思います。
src/main/resources/openapi.yaml
openapi: 3.0.3 info: title: People API version: 0.0.1 servers: - url: http://localhost:8080 - url: http://0.0.0.0:8080 paths: /people: get: tags: - people operationId: listPeople responses: '200': description: list People content: application/json: schema: type: array items: $ref: '#/components/schemas/PersonResponse' /people/{id}: get: tags: - people operationId: getPersonById parameters: - name: id in: path required: true schema: type: string responses: '200': description: get Person content: application/json: schema: $ref: '#/components/schemas/PersonResponse' put: tags: - people operationId: updatePersonById parameters: - name: id in: path required: true schema: type: string requestBody: content: application/json: schema: $ref: '#/components/schemas/PersonRequest' responses: '201': description: create or updated Person delete: tags: - people operationId: deletePersonById parameters: - name: id in: path required: true schema: type: string responses: 204: description: delete Person components: schemas: PersonRequest: required: - id - firstName - lastName type: object properties: id: type: string firstName: type: string maxLength: 10 lastName: type: string maxLength: 10 age: type: integer format: int32 maximum: 150 email: type: string format: email PersonResponse: required: - id - firstName - lastName type: object properties: id: type: string firstName: type: string maxLength: 10 lastName: type: string maxLength: 10 age: type: integer format: int32 maximum: 150 email: type: string format: email
Spring BootプロジェクトにOpenAPI Generatorの定義を追加して、ソースコードを生成する
では、Spring BootプロジェクトにOpenAPI Generatorの定義を追加します。
<properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <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}/src/main/resources/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>
依存関係にspringdoc-openapiを追加し、MavenプラグインとしてOpenAPI Generatorを追加しました。
OpenAPI Generatorではインターフェースのみの生成(interfaceOnly
をtrue
)とし、Spring Web MVC向けのソースコードを生成を
行います。
Documentation for the spring Generator | OpenAPI Generator
$ mvn 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/PersonResponse.java target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/PersonRequest.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/PeopleApi.java
リクエストに使うモデルの中身を見てみます。
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/PersonRequest.java
package org.littlewings.spring.openapi.generated.model; import java.net.URI; import java.util.Objects; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonCreator; import org.openapitools.jackson.nullable.JsonNullable; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import io.swagger.v3.oas.annotations.media.Schema; import java.util.*; import jakarta.annotation.Generated; /** * PersonRequest */ @Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2023-02-04T16:51:04.496237399+09:00[Asia/Tokyo]") public class PersonRequest { @JsonProperty("id") private String id; @JsonProperty("firstName") private String firstName; @JsonProperty("lastName") private String lastName; @JsonProperty("age") private Integer age; @JsonProperty("email") private String email; public PersonRequest id(String id) { this.id = id; return this; } /** * Get id * @return id */ @NotNull @Schema(name = "id", required = true) public String getId() { return id; } public void setId(String id) { this.id = id; } public PersonRequest firstName(String firstName) { this.firstName = firstName; return this; } /** * Get firstName * @return firstName */ @NotNull @Size(max = 10) @Schema(name = "firstName", required = true) public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public PersonRequest lastName(String lastName) { this.lastName = lastName; return this; } /** * Get lastName * @return lastName */ @NotNull @Size(max = 10) @Schema(name = "lastName", required = true) public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public PersonRequest age(Integer age) { this.age = age; return this; } /** * Get age * maximum: 150 * @return age */ @Max(150) @Schema(name = "age", required = false) public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public PersonRequest email(String email) { this.email = email; return this; } /** * Get email * @return email */ @Email @Schema(name = "email", required = false) public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } PersonRequest personRequest = (PersonRequest) o; return Objects.equals(this.id, personRequest.id) && Objects.equals(this.firstName, personRequest.firstName) && Objects.equals(this.lastName, personRequest.lastName) && Objects.equals(this.age, personRequest.age) && Objects.equals(this.email, personRequest.email); } @Override public int hashCode() { return Objects.hash(id, firstName, lastName, age, email); } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append("class PersonRequest {\n"); sb.append(" id: ").append(toIndentedString(id)).append("\n"); sb.append(" firstName: ").append(toIndentedString(firstName)).append("\n"); sb.append(" lastName: ").append(toIndentedString(lastName)).append("\n"); sb.append(" age: ").append(toIndentedString(age)).append("\n"); sb.append(" email: ").append(toIndentedString(email)).append("\n"); sb.append("}"); return sb.toString(); } /** * Convert the given object to string with each line indented by 4 spaces * (except the first line). */ private String toIndentedString(Object o) { if (o == null) { return "null"; } return o.toString().replace("\n", "\n "); } }
getterを見ると、バリデーションの定義が書かれていますね。
/** * Get id * @return id */ @NotNull @Schema(name = "id", required = true) public String getId() { return id; } /** * Get firstName * @return firstName */ @NotNull @Size(max = 10) @Schema(name = "firstName", required = true) public String getFirstName() { return firstName; } /** * Get lastName * @return lastName */ @NotNull @Size(max = 10) @Schema(name = "lastName", required = true) public String getLastName() { return lastName; } /** * Get age * maximum: 150 * @return age */ @Max(150) @Schema(name = "age", required = false) public Integer getAge() { return age; } /** * Get email * @return email */ @Email @Schema(name = "email", required = false) public String getEmail() { return email; }
とりあえず、ソースコードも作成しておきましょう。
エンティティ代わりのクラス。
src/main/java/org/littlewings/spring/openapi/entity/Person.java
package org.littlewings.spring.openapi.entity; public class Person { String id; String firstName; String lastName; int age; String email; // getter/setterは省略 }
Controller。データはメモリ上(ConcurrentHashMap
)で管理します。
src/main/java/org/littlewings/spring/openapi/controller/PeopleController.java
package org.littlewings.spring.openapi.controller; import java.util.ArrayList; import java.util.Comparator; 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.entity.Person; import org.littlewings.spring.openapi.generated.api.PeopleApi; import org.littlewings.spring.openapi.generated.model.PersonRequest; import org.littlewings.spring.openapi.generated.model.PersonResponse; import org.springframework.beans.BeanUtils; import org.springframework.http.ResponseEntity; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.validation.BindException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.util.UriComponentsBuilder; @RestController public class PeopleController implements PeopleApi { ConcurrentMap<String, Person> people = new ConcurrentHashMap<>(); NativeWebRequest request; public PeopleController(NativeWebRequest request) { this.request = request; } @Override public Optional<NativeWebRequest> getRequest() { return Optional.of(request); } @Override public ResponseEntity<Void> deletePersonById(String id) { people.remove(id); return ResponseEntity.noContent().build(); } @Override public ResponseEntity<PersonResponse> getPersonById(String id) { Person person = people.get(id); if (person != null) { PersonResponse response = new PersonResponse(); BeanUtils.copyProperties(person, response); return ResponseEntity.ok(response); } else { return ResponseEntity.notFound().build(); } } @Override public ResponseEntity<List<PersonResponse>> listPeople() { return ResponseEntity.ok( people .values() .stream() .map(p -> { PersonResponse response = new PersonResponse(); BeanUtils.copyProperties(p, response); return response; }) .sorted(Comparator.comparing(PersonResponse::getAge).reversed()) .toList() ); } @Override public ResponseEntity<Void> updatePersonById(String id, PersonRequest personRequest) { Person person = new Person(); BeanUtils.copyProperties(personRequest, person); people.put(id, person); UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder .fromHttpRequest( new ServletServerHttpRequest(getRequest().get().getNativeRequest(HttpServletRequest.class)) ); return ResponseEntity .created(uriComponentsBuilder.build().toUri()) .build(); } @ExceptionHandler(BindException.class) public ResponseEntity<Object> handleBindException(BindException exception) { List<String> fieldErrors = exception .getFieldErrors() .stream() .map(error -> String.format("%s: %s", error.getField(), error.getDefaultMessage())) .sorted() .toList(); List<String> globalErrors = exception .getGlobalErrors() .stream() .map(error -> error.getDefaultMessage()) .sorted() .toList(); List<String> errors = new ArrayList<>(); errors.addAll(fieldErrors); errors.addAll(globalErrors); return ResponseEntity.badRequest().body(errors); } }
動作確認してみます。
$ mvn spring-boot:run
データを用意。
data/katsuo.json
{ "id": "5f36aa19-4fa8-4fbb-9b48-80813031202c", "firstName": "カツオ", "lastName": "磯野", "age": 11, "email": "isono.katsuo@example.com" }
data/wakame.json
{ "id": "323f1cc7-971f-454f-bc7b-2a7f6258d23c", "firstName": "ワカメ", "lastName": "磯野", "age": 9, "email": "isono.wakame@example.com" }
確認。
## 登録 $ curl -XPUT localhost:8080/people/5f36aa19-4fa8-4fbb-9b48-80813031202c -H 'Content-Type: application/json' -d @data/katsuo.json $ curl -XPUT localhost:8080/people/323f1cc7-971f-454f-bc7b-2a7f6258d23c -H 'Content-Type: application/json' -d @data/wakame.json ## 全件取得 $ curl localhost:8080/people [{"id":"5f36aa19-4fa8-4fbb-9b48-80813031202c","firstName":"カツオ","lastName":"磯野","age":11,"email":"isono.katsuo@example.com"},{"id":"323f1cc7-971f-454f-bc7b-2a7f6258d23c","firstName":"ワカメ","lastName":"磯野","age":9,"email":"isono.wakame@example.com"}] ## 1件取得 $ curl localhost:8080/people/5f36aa19-4fa8-4fbb-9b48-80813031202c {"id":"5f36aa19-4fa8-4fbb-9b48-80813031202c","firstName":"カツオ","lastName":"磯野","age":11,"email":"isono.katsuo@example.com"} ## 削除 $ curl -XDELETE localhost:8080/people/323f1cc7-971f-454f-bc7b-2a7f6258d23c ## 再度、全件取得 $ curl localhost:8080/people [{"id":"5f36aa19-4fa8-4fbb-9b48-80813031202c","firstName":"カツオ","lastName":"磯野","age":11,"email":"isono.katsuo@example.com"}]
OKですね。
バリデーションでNGとなるデータも用意して試しましょう。
data/bad-data.json
{ "firstName": "詳細不明のどこそこの人", "lastName": "詳細不明のどこそこの人", "age": 900, "email": "test" }
確認。
$ curl -i -XPUT localhost:8080/people/d65c0f1f-bf6f-4ef7-93fa-395d913b908c -H 'Content-Type: application/json' -d @data/bad-data.json HTTP/1.1 400 Content-Type: application/json Transfer-Encoding: chunked Date: Sat, 04 Feb 2023 07:55:31 GMT Connection: close ["age: 150 以下の値にしてください","email: 電子メールアドレスとして正しい形式にしてください","firstName: 0 から 10 の間のサイズにしてください","id: null は許可されていません","lastName: 0 から 10 の間のサイズにしてください"]
こちらもOKですね。
OpenAPI Generatorのテンプレートをカスタマイズして、独自にバリデーションを追加する
ここからが本題です。OpenAPI Generatorの生成するソースコードをカスタマイズします。
OpenAPI GeneratorでSpring Web MVC向けのソースコード生成の際に使われるバリデーションの定義は、こちらです。
ここに、独自のバリデーションを出力するように変更してみましょう。
先にアノテーションを作成しておきます。
src/main/java/org/littlewings/spring/openapi/constraint/HtmlInputEmail.java
package org.littlewings.spring.openapi.constraint; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Repeatable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import jakarta.validation.Constraint; import jakarta.validation.Payload; import jakarta.validation.ReportAsSingleViolation; import jakarta.validation.constraints.Pattern; @Documented @Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = {}) @Pattern(regexp = "^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") @Repeatable(HtmlInputEmail.List.class) @ReportAsSingleViolation public @interface HtmlInputEmail { String message() default "メールアドレスとして正しい形式にしてください"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; @Documented @Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @interface List { HtmlInputEmail[] value(); } }
@Pattern
アノテーションを使って定義したもので、内容はWHATWGのinput要素で使えるメールアドレスの形式を指定しています。
The following JavaScript- and Perl-compatible regular expression is an implementation of the above definition.
/^[a-zA-Z0-9.!#$%&'+\/=?^_`{|}~-]+@a-zA-Z0-9?(?:.a-zA-Z0-9?)$/
次に、OpenAPI定義を変更します。今回はコピーして作成しました。
src/main/resources/openapi-custom-validation.yaml
openapi: 3.0.3 info: title: People API version: 0.0.1 servers: - url: http://localhost:8080 - url: http://0.0.0.0:8080 paths: /people: get: tags: - people operationId: listPeople responses: '200': description: list People content: application/json: schema: type: array items: $ref: '#/components/schemas/PersonResponse' /people/{id}: get: tags: - people operationId: getPersonById parameters: - name: id in: path required: true schema: type: string responses: '200': description: get Person content: application/json: schema: $ref: '#/components/schemas/PersonResponse' put: tags: - people operationId: updatePersonById parameters: - name: id in: path required: true schema: type: string requestBody: content: application/json: schema: $ref: '#/components/schemas/PersonRequest' responses: '201': description: create or updated Person delete: tags: - people operationId: deletePersonById parameters: - name: id in: path required: true schema: type: string responses: 204: description: delete Person components: schemas: PersonRequest: required: - id - firstName - lastName type: object properties: id: type: string firstName: type: string maxLength: 10 lastName: type: string maxLength: 10 age: type: integer format: int32 maximum: 150 email: type: string # format: email x-constraints: email: email PersonResponse: required: - id - firstName - lastName type: object properties: id: type: string firstName: type: string maxLength: 10 lastName: type: string maxLength: 10 age: type: integer format: int32 maximum: 150 email: type: string # format: email x-constraints: email: email
こんな感じで、フォーマットのemail
指定をやめて拡張仕様を使うようにしました。名前はx-constraints
としています。
email: type: string # format: email x-constraints: email: email
ちょっと不思議な形をしていますが、それはテンプレートを変更する箇所でまた説明します。
テンプレートの拡張方法は、以下のドキュメントに習います。
Using Templates | OpenAPI Generator
Mustacheで書かれていて、
GitHub - samskivert/jmustache: A Java implementation of the Mustache templating language.
テンプレートをオーバーライドできるという話でした。
Note: You cannot use this approach to create new templates, only override existing ones.
Using Templates / Modifying Templates
使用しているのはMavenプラグインですが、どうやってテンプレートを指定するかは書かれていません。
GitHubの方を見るとMavenプラグインについての記載があり、templateDirectory
を使えば良さそうだということがわかります。
directory with mustache templates
今回は、カスタマイズしたテンプレートをsrc/main/resources/openapi/templates
ディレクトリに置くことにします。
$ mkdir -p src/main/resources/openapi/templates
Spring向けのテンプレートはこちらに入っているので、
必要そうなものをダウンロードします。
$ curl -L https://github.com/OpenAPITools/openapi-generator/raw/v6.2.1/modules/openapi-generator/src/main/resources/JavaSpring/beanValidation.mustache -o src/main/resources/openapi/templates/beanValidation.mustache $ curl -L https://raw.githubusercontent.com/OpenAPITools/openapi-generator/v6.2.1/modules/openapi-generator/src/main/resources/JavaSpring/beanValidationCore.mustache -o src/main/resources/openapi/templates/beanValidationCore.mustache $ curl -L https://github.com/OpenAPITools/openapi-generator/raw/v6.2.1/modules/openapi-generator/src/main/resources/JavaSpring/model.mustache -o src/main/resources/openapi/templates/model.mustache $ curl -L https://github.com/OpenAPITools/openapi-generator/raw/v6.2.1/modules/openapi-generator/src/main/resources/JavaSpring/api.mustache -o src/main/resources/openapi/templates/api.mustache $ curl -L https://github.com/OpenAPITools/openapi-generator/raw/v6.2.1/modules/openapi-generator/src/main/resources/JavaSpring/apiController.mustache -o src/main/resources/openapi/templates/apiController.mustache
beanValidation.mustache
、beanValidationCore.mustache
、model.mustache
、api.mustache
、apiController.mustache
の5つの
ファイルをなんとなくダウンロードしましたが、実際に修正したのはbeanValidationCore.mustache
、model.mustache
、api.mustache
、
apiController.mustache
の4ファイルになりました。
Controller(インターフェースとクラス)とモデルにimport
を追加。
src/main/resources/openapi/templates/api.mustache
/** * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) ({{{generatorVersion}}}). * https://openapi-generator.tech * Do not edit the class manually. */ package {{package}}; {{#imports}}import {{import}}; {{/imports}} {{#swagger2AnnotationLibrary}} 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; {{/swagger2AnnotationLibrary}} {{#swagger1AnnotationLibrary}} import io.swagger.annotations.*; {{/swagger1AnnotationLibrary}} {{#jdk8-no-delegate}} {{#virtualService}} import io.virtualan.annotation.ApiVirtual; import io.virtualan.annotation.VirtualService; {{/virtualService}} import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; {{/jdk8-no-delegate}} import org.springframework.http.ResponseEntity; {{#useBeanValidation}} import org.springframework.validation.annotation.Validated; {{/useBeanValidation}} {{#useSpringController}} import org.springframework.stereotype.Controller; {{/useSpringController}} import org.springframework.web.bind.annotation.*; {{#jdk8-no-delegate}} {{^reactive}} import org.springframework.web.context.request.NativeWebRequest; {{/reactive}} {{/jdk8-no-delegate}} import org.springframework.web.multipart.MultipartFile; {{#reactive}} import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.http.codec.multipart.Part; {{/reactive}} {{#useBeanValidation}} {{#useJakartaEe}} import jakarta.validation.Valid; import jakarta.validation.constraints.*; {{/useJakartaEe}} {{^useJakartaEe}} import javax.validation.Valid; import javax.validation.constraints.*; {{/useJakartaEe}} import org.littlewings.spring.openapi.constraint.*; // 追加 {{/useBeanValidation}} import java.util.List; import java.util.Map; {{#jdk8-no-delegate}} import java.util.Optional; {{/jdk8-no-delegate}} {{^jdk8-no-delegate}} {{#useOptional}} import java.util.Optional; {{/useOptional}} {{/jdk8-no-delegate}} {{#async}} import java.util.concurrent.CompletableFuture; {{/async}} {{#useJakartaEe}} import jakarta.annotation.Generated; {{/useJakartaEe}} {{^useJakartaEe}} import javax.annotation.Generated; {{/useJakartaEe}} {{>generatedAnnotation}} {{#useBeanValidation}} @Validated {{/useBeanValidation}} {{#useSpringController}} @Controller {{/useSpringController}} {{#swagger2AnnotationLibrary}} @Tag(name = "{{{baseName}}}", description = {{#tagDescription}}"{{{.}}}"{{/tagDescription}}{{^tagDescription}}"the {{{baseName}}} API"{{/tagDescription}}) {{/swagger2AnnotationLibrary}} {{#swagger1AnnotationLibrary}} @Api(value = "{{{baseName}}}", description = {{#tagDescription}}"{{{.}}}"{{/tagDescription}}{{^tagDescription}}"the {{{baseName}}} API"{{/tagDescription}}) {{/swagger1AnnotationLibrary}} {{#operations}} {{#virtualService}} @VirtualService {{/virtualService}} {{#useRequestMappingOnInterface}} {{=<% %>=}} @RequestMapping("${openapi.<%title%>.base-path:<%>defaultBasePath%>}") <%={{ }}=%> {{/useRequestMappingOnInterface}} public interface {{classname}} { {{#jdk8-default-interface}} {{^isDelegate}} {{^reactive}} default Optional<NativeWebRequest> getRequest() { return Optional.empty(); } {{/reactive}} {{/isDelegate}} {{#isDelegate}} default {{classname}}Delegate getDelegate() { return new {{classname}}Delegate() {}; } {{/isDelegate}} {{/jdk8-default-interface}} {{#operation}} /** * {{httpMethod}} {{{path}}}{{#summary}} : {{.}}{{/summary}} {{#notes}} * {{.}} {{/notes}} * {{#allParams}} * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}} {{/allParams}} * @return {{#responses}}{{message}} (status code {{code}}){{^-last}} * or {{/-last}}{{/responses}} {{#isDeprecated}} * @deprecated {{/isDeprecated}} {{#externalDocs}} * {{description}} * @see <a href="{{url}}">{{summary}} Documentation</a> {{/externalDocs}} */ {{#isDeprecated}} @Deprecated {{/isDeprecated}} {{#virtualService}} @ApiVirtual {{/virtualService}} {{#swagger2AnnotationLibrary}} @Operation( operationId = "{{{operationId}}}", {{#summary}} summary = "{{{.}}}", {{/summary}} {{#description}} description= "{{{.}}}", {{/description}} {{#vendorExtensions.x-tags.size}} tags = { {{#vendorExtensions.x-tags}}"{{tag}}"{{^-last}}, {{/-last}}{{/vendorExtensions.x-tags}} }, {{/vendorExtensions.x-tags.size}} responses = { {{#responses}} @ApiResponse(responseCode = "{{{code}}}", description = "{{{message}}}"{{#baseType}}, content = { {{#produces}} @Content(mediaType = "{{{mediaType}}}", schema = @Schema(implementation = {{{baseType}}}.class)){{^-last}},{{/-last}} {{/produces}} }{{/baseType}}){{^-last}},{{/-last}} {{/responses}} }{{#hasAuthMethods}}, security = { {{#authMethods}} @SecurityRequirement(name = "{{name}}"{{#isOAuth}}, scopes={ {{#scopes}}"{{scope}}"{{^-last}}, {{/-last}}{{/scopes}} }{{/isOAuth}}){{^-last}},{{/-last}} {{/authMethods}} }{{/hasAuthMethods}} ) {{/swagger2AnnotationLibrary}} {{#swagger1AnnotationLibrary}} @ApiOperation( {{#vendorExtensions.x-tags.size}} tags = { {{#vendorExtensions.x-tags}}"{{tag}}"{{^-last}}, {{/-last}}{{/vendorExtensions.x-tags}} }, {{/vendorExtensions.x-tags.size}} value = "{{{summary}}}", nickname = "{{{operationId}}}", notes = "{{{notes}}}"{{#returnBaseType}}, response = {{{.}}}.class{{/returnBaseType}}{{#returnContainer}}, responseContainer = "{{{.}}}"{{/returnContainer}}{{#hasAuthMethods}}, authorizations = { {{#authMethods}} {{#isOAuth}} @Authorization(value = "{{name}}", scopes = { {{#scopes}} @AuthorizationScope(scope = "{{scope}}", description = "{{description}}"){{^-last}},{{/-last}} {{/scopes}} }){{^-last}},{{/-last}} {{/isOAuth}} {{^isOAuth}} @Authorization(value = "{{name}}"){{^-last}},{{/-last}} {{/isOAuth}} {{/authMethods}} }{{/hasAuthMethods}} ) @ApiResponses({ {{#responses}} @ApiResponse(code = {{{code}}}, message = "{{{message}}}"{{#baseType}}, response = {{{.}}}.class{{/baseType}}{{#containerType}}, responseContainer = "{{{.}}}"{{/containerType}}){{^-last}},{{/-last}} {{/responses}} }) {{/swagger1AnnotationLibrary}} {{#implicitHeadersParams.0}} {{#swagger2AnnotationLibrary}} @Parameters({ {{#implicitHeadersParams}} {{>paramDoc}}{{^-last}},{{/-last}} {{/implicitHeadersParams}} }) {{/swagger2AnnotationLibrary}} {{#swagger1AnnotationLibrary}} @ApiImplicitParams({ {{#implicitHeadersParams}} {{>implicitHeader}}{{^-last}},{{/-last}} {{/implicitHeadersParams}} }) {{/swagger1AnnotationLibrary}} {{/implicitHeadersParams.0}} @RequestMapping( method = RequestMethod.{{httpMethod}}, value = "{{{path}}}"{{#singleContentTypes}}{{#hasProduces}}, produces = "{{{vendorExtensions.x-accepts}}}"{{/hasProduces}}{{#hasConsumes}}, consumes = "{{{vendorExtensions.x-content-type}}}"{{/hasConsumes}}{{/singleContentTypes}}{{^singleContentTypes}}{{#hasProduces}}, produces = { {{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}} }{{/hasProduces}}{{#hasConsumes}}, consumes = { {{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}} }{{/hasConsumes}}{{/singleContentTypes}} ) {{#jdk8-default-interface}}default {{/jdk8-default-interface}}{{#responseWrapper}}{{.}}<{{/responseWrapper}}ResponseEntity<{{>returnTypes}}>{{#responseWrapper}}>{{/responseWrapper}} {{#delegate-method}}_{{/delegate-method}}{{operationId}}( {{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}}{{>cookieParams}}{{^-last}}, {{/-last}}{{/allParams}}{{#reactive}}{{#hasParams}}, {{/hasParams}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true){{/swagger2AnnotationLibrary}}{{#springFoxDocumentationProvider}}@ApiIgnore{{/springFoxDocumentationProvider}} final ServerWebExchange exchange{{/reactive}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{#springFoxDocumentationProvider}}@ApiIgnore {{/springFoxDocumentationProvider}}{{#springDocDocumentationProvider}}@ParameterObject {{/springDocDocumentationProvider}}final Pageable pageable{{/vendorExtensions.x-spring-paginated}} ){{#unhandledException}} throws Exception{{/unhandledException}}{{^jdk8-default-interface}};{{/jdk8-default-interface}}{{#jdk8-default-interface}} { {{#delegate-method}} return {{operationId}}({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#reactive}}{{#hasParams}}, {{/hasParams}}exchange{{/reactive}}{{#vendorExtensions.x-spring-paginated}}, pageable{{/vendorExtensions.x-spring-paginated}}); } // Override this method {{#jdk8-default-interface}}default {{/jdk8-default-interface}} {{#responseWrapper}}{{.}}<{{/responseWrapper}}ResponseEntity<{{>returnTypes}}>{{#responseWrapper}}>{{/responseWrapper}} {{operationId}}({{#allParams}}{{^isFile}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{{dataType}}}{{/reactive}}{{#reactive}}{{^isArray}}Mono<{{{dataType}}}>{{/isArray}}{{#isArray}}Flux<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{/isFile}}{{#isFile}}{{#reactive}}Flux<Part>{{/reactive}}{{^reactive}}MultipartFile{{/reactive}}{{/isFile}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#reactive}}{{#hasParams}}, {{/hasParams}}{{#springFoxDocumentationProvider}}@ApiIgnore{{/springFoxDocumentationProvider}} final ServerWebExchange exchange{{/reactive}}{{#vendorExtensions.x-spring-paginated}}, {{#springFoxDocumentationProvider}}@ApiIgnore{{/springFoxDocumentationProvider}} final Pageable pageable{{/vendorExtensions.x-spring-paginated}}){{#unhandledException}} throws Exception{{/unhandledException}} { {{/delegate-method}} {{^isDelegate}} {{>methodBody}} {{/isDelegate}} {{#isDelegate}} return getDelegate().{{operationId}}({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#reactive}}{{#hasParams}}, {{/hasParams}}exchange{{/reactive}}{{#vendorExtensions.x-spring-paginated}}, pageable{{/vendorExtensions.x-spring-paginated}}); {{/isDelegate}} }{{/jdk8-default-interface}} {{/operation}} } {{/operations}}
src/main/resources/openapi/templates/apiController.mustache
package {{package}}; {{#imports}}import {{import}}; {{/imports}} {{#_api_controller_impl_}} {{#swagger2AnnotationLibrary}} import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; 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; {{/swagger2AnnotationLibrary}} {{#swagger1AnnotationLibrary}} import io.swagger.annotations.*; {{/swagger1AnnotationLibrary}} {{/_api_controller_impl_}} 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; {{^isDelegate}} import org.springframework.web.context.request.NativeWebRequest; {{/isDelegate}} {{#useBeanValidation}} {{#useJakartaEe}} import jakarta.validation.Valid; import jakarta.validation.constraints.*; {{/useJakartaEe}} {{^useJakartaEe}} import javax.validation.constraints.*; import javax.validation.Valid; {{/useJakartaEe}} import org.littlewings.spring.openapi.constraint.*; // 追加 {{/useBeanValidation}} import java.util.List; import java.util.Map; import java.util.Optional; {{#useJakartaEe}} import jakarta.annotation.Generated; {{/useJakartaEe}} {{^useJakartaEe}} import javax.annotation.Generated; {{/useJakartaEe}} {{>generatedAnnotation}} @Controller {{#useRequestMappingOnController}} {{=<% %>=}} @RequestMapping("${openapi.<%title%>.base-path:<%>defaultBasePath%>}") <%={{ }}=%> {{/useRequestMappingOnController}} {{#operations}} public class {{classname}}Controller implements {{classname}} { {{#isDelegate}} private final {{classname}}Delegate delegate; public {{classname}}Controller(@Autowired(required = false) {{classname}}Delegate delegate) { this.delegate = Optional.ofNullable(delegate).orElse(new {{classname}}Delegate() {}); } @Override public {{classname}}Delegate getDelegate() { return delegate; } {{/isDelegate}} {{^isDelegate}} {{^reactive}} private final NativeWebRequest request; @Autowired public {{classname}}Controller(NativeWebRequest request) { this.request = request; } @Override public Optional<NativeWebRequest> getRequest() { return Optional.ofNullable(request); } {{/reactive}} {{/isDelegate}} {{#_api_controller_impl_}} {{#operation}} /** * {{httpMethod}} {{{path}}}{{#summary}} : {{.}}{{/summary}} {{#notes}} * {{.}} {{/notes}} * {{#allParams}} * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}} {{/allParams}} * @return {{#responses}}{{message}} (status code {{code}}){{^-last}} * or {{/-last}}{{/responses}} {{#isDeprecated}} * @deprecated {{/isDeprecated}} {{#externalDocs}} * {{description}} * @see <a href="{{url}}">{{summary}} Documentation</a> {{/externalDocs}} * @see {{classname}}#{{operationId}} */ {{#isDeprecated}} @Deprecated {{/isDeprecated}} public {{#responseWrapper}}{{.}}<{{/responseWrapper}}ResponseEntity<{{>returnTypes}}>{{#responseWrapper}}>{{/responseWrapper}} {{operationId}}( {{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}}{{>cookieParams}}{{^-last}}, {{/-last}}{{/allParams}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{#springFoxDocumentationProvider}}@ApiIgnore {{/springFoxDocumentationProvider}}final Pageable pageable{{/vendorExtensions.x-spring-paginated}} ) { {{^isDelegate}} {{^async}} {{>methodBody}} {{/async}} {{#async}} return new Callable<ResponseEntity<{{>returnTypes}}>>() { @Override public ResponseEntity<{{>returnTypes}}> call() { {{>methodBody}} } }; {{/async}} {{/isDelegate}} {{#isDelegate}} return delegate.{{operationId}}({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}}{{#vendorExtensions.x-spring-paginated}}, pageable{{/vendorExtensions.x-spring-paginated}}); {{/isDelegate}} } {{/operation}} {{/_api_controller_impl_}} } {{/operations}}
src/main/resources/openapi/templates/model.mustache
package {{package}}; import java.net.URI; import java.util.Objects; {{#imports}}import {{import}}; {{/imports}} {{#openApiNullable}} import org.openapitools.jackson.nullable.JsonNullable; {{/openApiNullable}} {{#serializableModel}} import java.io.Serializable; {{/serializableModel}} import java.time.OffsetDateTime; {{#useBeanValidation}} {{#useJakartaEe}} import jakarta.validation.Valid; import jakarta.validation.constraints.*; {{/useJakartaEe}} {{^useJakartaEe}} import javax.validation.Valid; import javax.validation.constraints.*; {{/useJakartaEe}} import org.littlewings.spring.openapi.constraint.*; // 追加 {{/useBeanValidation}} {{#performBeanValidation}} import org.hibernate.validator.constraints.*; {{/performBeanValidation}} {{#jackson}} {{#withXml}} import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; {{/withXml}} {{/jackson}} {{#swagger2AnnotationLibrary}} import io.swagger.v3.oas.annotations.media.Schema; {{/swagger2AnnotationLibrary}} {{#withXml}} import javax.xml.bind.annotation.*; {{/withXml}} {{^parent}} {{#hateoas}} import org.springframework.hateoas.RepresentationModel; {{/hateoas}} {{/parent}} import java.util.*; {{#useJakartaEe}} import jakarta.annotation.Generated; {{/useJakartaEe}} {{^useJakartaEe}} import javax.annotation.Generated; {{/useJakartaEe}} {{#models}} {{#model}} {{#isEnum}} {{>enumOuterClass}} {{/isEnum}} {{^isEnum}} {{#vendorExtensions.x-is-one-of-interface}}{{>oneof_interface}}{{/vendorExtensions.x-is-one-of-interface}}{{^vendorExtensions.x-is-one-of-interface}}{{>pojo}}{{/vendorExtensions.x-is-one-of-interface}} {{/isEnum}} {{/model}} {{/models}}
この部分ですね。
import org.littlewings.spring.openapi.constraint.*; // 追加
Controllerのクラス向けの方は、今回はinterfaceOnly
にしているので使われないのですが。
バリデーション定義。
src/main/resources/openapi/templates/beanValidationCore.mustache
{{#pattern}}{{^isByteArray}}@Pattern(regexp = "{{{pattern}}}") {{/isByteArray}}{{/pattern}}{{! minLength && maxLength set }}{{#minLength}}{{#maxLength}}@Size(min = {{minLength}}, max = {{maxLength}}) {{/maxLength}}{{/minLength}}{{! minLength set, maxLength not }}{{#minLength}}{{^maxLength}}@Size(min = {{minLength}}) {{/maxLength}}{{/minLength}}{{! minLength not set, maxLength set }}{{^minLength}}{{#maxLength}}@Size(max = {{.}}) {{/maxLength}}{{/minLength}}{{! @Size: minItems && maxItems set }}{{#minItems}}{{#maxItems}}@Size(min = {{minItems}}, max = {{maxItems}}) {{/maxItems}}{{/minItems}}{{! @Size: minItems set, maxItems not }}{{#minItems}}{{^maxItems}}@Size(min = {{minItems}}) {{/maxItems}}{{/minItems}}{{! @Size: minItems not set && maxItems set }}{{^minItems}}{{#maxItems}}@Size(max = {{.}}) {{/maxItems}}{{/minItems}}{{! @Email: useBeanValidation set && isEmail set }}{{#useBeanValidation}}{{#isEmail}}@Email{{/isEmail}}{{/useBeanValidation}}{{! check for integer or long / all others=decimal type with @Decimal* isInteger set }}{{#isInteger}}{{#minimum}}@Min({{.}}) {{/minimum}}{{#maximum}}@Max({{.}}) {{/maximum}}{{/isInteger}}{{! isLong set }}{{#isLong}}{{#minimum}}@Min({{.}}L) {{/minimum}}{{#maximum}}@Max({{.}}L) {{/maximum}}{{/isLong}}{{! Not Integer, not Long => we have a decimal value! }}{{^isInteger}}{{^isLong}}{{#minimum}}@DecimalMin({{#exclusiveMinimum}}value = {{/exclusiveMinimum}}"{{minimum}}"{{#exclusiveMinimum}}, inclusive = false{{/exclusiveMinimum}}) {{/minimum}}{{#maximum}}@DecimalMax({{#exclusiveMaximum}}value = {{/exclusiveMaximum}}"{{maximum}}"{{#exclusiveMaximum}}, inclusive = false{{/exclusiveMaximum}}) {{/maximum}}{{/isLong}}{{/isInteger}}{{! custom constraints }}{{#useBeanValidation}}{{#vendorExtensions.x-constraints}}{{! @HtmlInputEmail }}{{#email}}@HtmlInputEmail {{/email}}{{/vendorExtensions.x-constraints}}{{/useBeanValidation}}
ここが、追加した部分ですね。
{{! custom constraints }}{{#useBeanValidation}}{{#vendorExtensions.x-constraints}}{{! @HtmlInputEmail }}{{#email}}@HtmlInputEmail {{/email}}{{/vendorExtensions.x-constraints}}{{/useBeanValidation}}
x-
で始まる拡張仕様は、vendorExtensions
を介して参照できるようです。
ところで、x-constraints
として定義した内容はこんな感じでした。
email: type: string # format: email x-constraints: email: email
まわりくどくせず、こんな感じにして
email: type: string x-constraints: @HtmlInputEmail
テンプレートはそのまま値を出力するようにしてもよかったのですが。
{{! custom constraints }}{{#useBeanValidation}}{{vendorExtensions.x-constraints}}{{/useBeanValidation}}
こうすると、ひとつしか付けられないなと思って今回の形にしました。
こうすれば、独自のバリでションを複数定義できますからね。
email: type: string x-constraints: email: email valid-domain: valid-domain
テンプレートはこんな感じになります。@ValidDomain
なんていうアノテーションは、今回は作成していませんが。
{{! custom constraints }}{{#useBeanValidation}}{{#vendorExtensions.x-constraints}}{{! @HtmlInputEmail }}{{#email}}@HtmlInputEmail {{/email}}{{! @ValidDomain }}{{#valid-domain}}@ValidDomain {{/valid-domain}}{{/vendorExtensions.x-constraints}}{{/useBeanValidation}}
追加した拡張属性の子要素に同じ名前を使っているのは、値はなんでも良かったのですが階層構造にしてMustacheテンプレートから
参照しやすくしたかっただけです…。
アノテーションに値を指定する必要がある場合も、この形だと対応できるでしょうし。
そうでなければ、今回の例だとテンプレートに書かれている@Email
アノテーションから自前のアノテーションに置き換えれば済む話
ですからね。
ちなみに、今回修正しなかったbeanValidation.mustache
には、こんな内容が書かれています。
src/main/resources/openapi/templates/beanValidation.mustache
{{#required}}{{^isReadOnly}}@NotNull {{/isReadOnly}}{{/required}}{{#isContainer}}{{^isPrimitiveType}}{{^isEnum}}@Valid {{/isEnum}}{{/isPrimitiveType}}{{/isContainer}}{{^isContainer}}{{^isPrimitiveType}}@Valid {{/isPrimitiveType}}{{/isContainer}}{{>beanValidationCore}
最後に、OpenAPI GeneratorのMavenプラグインの設定を変更します。今回はOpenAPIの定義を別ファイルにしたので、参照先の変更と、
templateDirectory
で差分の
<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}/src/main/resources/openapi.yaml</inputSpec> --> <inputSpec>${project.basedir}/src/main/resources/openapi-custom-validation.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> <templateDirectory>${project.basedir}/src/main/resources/openapi/templates</templateDirectory> </configuration> </execution> </executions> </plugin>
では、コンパイル。
$ mvn compile
生成されたコードを確認してみます。
まず、import
文が追加されていることを確認。
$ grep -r openapi.constraint target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/ target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/PersonResponse.java:import org.littlewings.spring.openapi.constraint.*; // 追加 target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/PersonRequest.java:import org.littlewings.spring.openapi.constraint.*; // 追加 target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/PeopleApi.java:import org.littlewings.spring.openapi.constraint.*; // 追加
モデルを確認。
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/model/PersonRequest.java
package org.littlewings.spring.openapi.generated.model; import java.net.URI; import java.util.Objects; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonCreator; import org.openapitools.jackson.nullable.JsonNullable; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import org.littlewings.spring.openapi.constraint.*; // 追加 import io.swagger.v3.oas.annotations.media.Schema; import java.util.*; import jakarta.annotation.Generated; /** * PersonRequest */ @Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2023-02-04T23:57:40.942582178+09:00[Asia/Tokyo]") public class PersonRequest { @JsonProperty("id") private String id; @JsonProperty("firstName") private String firstName; @JsonProperty("lastName") private String lastName; @JsonProperty("age") private Integer age; @JsonProperty("email") private String email; public PersonRequest id(String id) { this.id = id; return this; } /** * Get id * @return id */ @NotNull @Schema(name = "id", required = true) public String getId() { return id; } public void setId(String id) { this.id = id; } public PersonRequest firstName(String firstName) { this.firstName = firstName; return this; } /** * Get firstName * @return firstName */ @NotNull @Size(max = 10) @Schema(name = "firstName", required = true) public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public PersonRequest lastName(String lastName) { this.lastName = lastName; return this; } /** * Get lastName * @return lastName */ @NotNull @Size(max = 10) @Schema(name = "lastName", required = true) public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public PersonRequest age(Integer age) { this.age = age; return this; } /** * Get age * maximum: 150 * @return age */ @Max(150) @Schema(name = "age", required = false) public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public PersonRequest email(String email) { this.email = email; return this; } /** * Get email * @return email */ @HtmlInputEmail @Schema(name = "email", required = false) public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } PersonRequest personRequest = (PersonRequest) o; return Objects.equals(this.id, personRequest.id) && Objects.equals(this.firstName, personRequest.firstName) && Objects.equals(this.lastName, personRequest.lastName) && Objects.equals(this.age, personRequest.age) && Objects.equals(this.email, personRequest.email); } @Override public int hashCode() { return Objects.hash(id, firstName, lastName, age, email); } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append("class PersonRequest {\n"); sb.append(" id: ").append(toIndentedString(id)).append("\n"); sb.append(" firstName: ").append(toIndentedString(firstName)).append("\n"); sb.append(" lastName: ").append(toIndentedString(lastName)).append("\n"); sb.append(" age: ").append(toIndentedString(age)).append("\n"); sb.append(" email: ").append(toIndentedString(email)).append("\n"); sb.append("}"); return sb.toString(); } /** * Convert the given object to string with each line indented by 4 spaces * (except the first line). */ private String toIndentedString(Object o) { if (o == null) { return "null"; } return o.toString().replace("\n", "\n "); } }
ポイントとなる独自のアノテーションが付与されています。
/** * Get email * @return email */ @HtmlInputEmail @Schema(name = "email", required = false) public String getEmail() { return email; }
動作確認しておきましょう。
$ mvn spring-boot:run
返ってくるメッセージが、@HtmlInputEmail
アノテーションに書いたものと同じになっているので、ちゃんと効いているようです。
$ curl -i -XPUT localhost:8080/people/d65c0f1f-bf6f-4ef7-93fa-395d913b908c -H 'Content-T ype: application/json' -d @data/bad-data.json HTTP/1.1 400 Content-Type: application/json Transfer-Encoding: chunked Date: Sat, 04 Feb 2023 15:18:51 GMT Connection: close ["age: 150 以下の値にしてください","email: メールアドレスとして正しい形式にしてください","firstName: 0 から 10 の間のサイズにしてください","id: null は許可されていません","lastName: 0 から 10 の間のサイズにしてください"]
ちなみに、もうひとつ拡張属性を追加した例も記載しましたが、その状態で生成するとこのようになります。
/** * Get email * @return email */ @HtmlInputEmail @ValidDomain @Schema(name = "email", required = false) public String getEmail() { return email; }
まとめ
OpenAPI Generatorを使って生成するSpring Web MVCのソースコードに、独自にバリデーションを追加してみました。
Mustacheテンプレートを用意すればなんとかなるわけですが、久しぶりにMustacheテンプレートを触ったのと、使用するOpenAPI Generatorの
バージョンを上げると再度変更内容を入れることになるのでそのあたりの運用面は気になるところではありますが。
OpenAPI Generatorを使う上で最も簡単な拡張方法のようなので、押さえておくとしましょうか。