CLOVER🍀

That was when it all began.

OpenAPI Generatorを使って生成するSpring Web MVCのソースコードに、独自にバリデーションを追加したい

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

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 / Validation APIs / Bootstrapping / XML configuration: META-INF/validation.xml

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-...]}}というように参照することが
できます。

https://github.com/OpenAPITools/openapi-generator/blob/v6.2.1/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java#L620-L622

実際に、x-の拡張仕様を扱っているのはSwaggerみたいですけどね。

https://github.com/swagger-api/swagger-core/blob/v2.2.7/modules/swagger-core/src/main/java/io/swagger/v3/core/util/ApiResponsesDeserializer.java#L38-L40

https://github.com/swagger-api/swagger-core/blob/v2.2.7/modules/swagger-core/src/main/java/io/swagger/v3/core/util/PathsDeserializer.java#L39-L41

https://github.com/swagger-api/swagger-core/blob/v2.2.7/modules/swagger-core/src/main/java/io/swagger/v3/core/util/CallbackDeserializer.java#L38-L40

そして、OpenAPI Generatorのテンプレートは変更することができ、これでOpenAPI Generatorが生成するソースコードをカスタマイズ
することができます。

Using Templates

自分で作成したテンプレートで、もともと組み込まれているテンプレートを上書きするものみたいですけどね。

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)

https://github.com/OpenAPITools/openapi-generator/blob/v6.2.1/modules/openapi-generator/src/main/resources/JavaSpring/beanValidationCore.mustache#L14

こちらは残したままで、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?)$/

HTML / The elements of HTML / Forms / The input element / States of the type attribute / Email state / valid email address

環境

今回の環境は、こちら。

$ 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

コンパイル。

$ 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向けのソースコード生成の際に使われるバリデーションの定義は、こちらです。

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

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

ここに、独自のバリデーションを出力するように変更してみましょう。

先にアノテーションを作成しておきます。

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?)$/

HTML / The elements of HTML / Forms / The input element / States of the type attribute / Email state / valid email address

次に、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

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プラグインですが、どうやってテンプレートを指定するかは書かれていません。

Plugins

GitHubの方を見るとMavenプラグインについての記載があり、templateDirectoryを使えば良さそうだということがわかります。

directory with mustache templates

https://github.com/OpenAPITools/openapi-generator/blob/v6.2.1/modules/openapi-generator-maven-plugin/README.md

今回は、カスタマイズしたテンプレートをsrc/main/resources/openapi/templatesディレクトリに置くことにします。

$ mkdir -p src/main/resources/openapi/templates

Spring向けのテンプレートはこちらに入っているので、

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

必要そうなものをダウンロードします。

$ 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を介して参照できるようです。

https://github.com/OpenAPITools/openapi-generator/blob/v6.2.1/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java#L620-L622

ところで、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を使う上で最も簡単な拡張方法のようなので、押さえておくとしましょうか。