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.xmlconstraint-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プロジェクトを作成します。依存関係には、webvalidationを指定。

$ 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ではインターフェースのみの生成(interfaceOnlytrue)とし、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.mustachebeanValidationCore.mustachemodel.mustacheapi.mustacheapiController.mustacheの5つの
ファイルをなんとなくダウンロードしましたが、実際に修正したのはbeanValidationCore.mustachemodel.mustacheapi.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を使う上で最も簡単な拡張方法のようなので、押さえておくとしましょうか。

Docker Composeで、構成ファイルを複数使って定義内容を上書きする

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

Docker Composeで、構成ファイルを複数使って上書きができることはなんとなく知っていたのですが、ちゃんと情報を追ったことが
なかったので、ちょっと見てみることにしました。

Docker Composeでの構成の上書き

ドキュメントとしては、以下が該当します。

Share Compose configurations between files and projects | Docker Documentation

複数のComposeのファイルを使うことで、アプリケーションを様々な環境やワークロードに合わせてカスタマイズできるという仕組みです。

Using multiple Compose files lets you to customize a Compose application for different environments or different workflows.

Share Compose configurations between files and projects / Multiple Compose files

デフォルトでは、docker-compose.ymlとオプションのdocker-compose.override.ymlが読み込まれると書かれています。

By default, Compose reads two files, a docker-compose.yml and an optional docker-compose.override.yml file.

docker-compose.ymlの方が、基本構成ですね。docker-compose.override.ymlで、基本構成を上書きすることになります。

サービスが両方のファイルで定義されている場合は、マージが行われるようです。

If a service is defined in both files, Compose merges the configurations using the rules described in Adding and overriding configuration.

Share Compose configurations between files and projects / Multiple Compose files / Understanding multiple Compose files

マージのルールは、こちらに書かれています。

Share Compose configurations between files and projects / Adding and overriding configuration

複数の値を取る設定については、両方のファイルの内容を足し合わせたものになります。
environtmentlabelは名前を考慮したマージが行われるようです。

単一の値の場合は、上書き用のファイルの内容が採用されます。

あと、今回の内容とは関係ないですが、ある構成ファイルの内容を拡張(というか共通化)もできるようです。

Share Compose configurations between files and projects / Extending services

今回は、上書きについて試してみます。

環境

今回の環境は、こちら。

Docker Engine。

$ docker version
Client: Docker Engine - Community
 Version:           20.10.23
 API version:       1.41
 Go version:        go1.18.10
 Git commit:        7155243
 Built:             Thu Jan 19 17:45:08 2023
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.23
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.18.10
  Git commit:       6051f14
  Built:            Thu Jan 19 17:42:57 2023
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.15
  GitCommit:        5b842e528e99d4d4c1686467debf2bd4b88ecd86
 runc:
  Version:          1.1.4
  GitCommit:        v1.1.4-0-g5fd4c4d
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Docker Compose。

$ docker compose version
Docker Compose version v2.15.1

基本の構成ファイル

今回は、こちらのファイルを基本構成にします。

compose.yaml

services:
  proxy:
    image: nginx:1.23.3
    volumes:
      - ./default.conf:/etc/nginx/conf.d/default.conf
    ports:
      - "80:80"
    deploy:
      resources:
        limits:
          cpus: '1'
        reservations:
          cpus: '1'

  webapp:
    image: quay.io/wildfly/wildfly:27.0.0.Final-jdk17
    deploy:
      resources:
        limits:
          cpus: '1'
        reservations:
          cpus: '1'

ファイル名については、compose.yamlとするのが推奨のようなので、こちらに合わせます。

Compose specification / Compose file

WildFlyの前に、nginxをリバースプロキシとして置く構成にします。

default.conf

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        proxy_pass http://webapp:8080/;
    }
}

こちらで、動作確認しておきます。

$ docker compose up

ローカルにバインドされているポートの確認。

$ docker compose ps
NAME                              IMAGE                                        COMMAND                  SERVICE             CREATED             STATUS              PORTS
configuration-override-proxy-1    nginx:1.23.3                                 "/docker-entrypoint.…"   proxy               5 seconds ago       Up 3 seconds        0.0.0.0:80->80/tcp, :::80->80/tcp
configuration-override-webapp-1   quay.io/wildfly/wildfly:27.0.0.Final-jdk17   "/opt/jboss/wildfly/…"   webapp              5 seconds ago       Up 3 seconds        8080/tcp

アクセス確認。

$ curl localhost
<!DOCTYPE html>

<html>
<head>
    <!-- proper charset -->
    <meta http-equiv="content-type" content="text/html;charset=utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=EmulateIE8" />

    <title>Welcome to WildFly</title>
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
    <link rel="StyleSheet" href="wildfly.css" type="text/css">
</head>

<body>
<div class="wrapper">
    <div class="content">
        <div class="logo">
                <img src="wildfly_logo.png" alt="WildFly" border="0" />
        </div>
        <h1>Welcome to WildFly</h1>

        <h3>Your WildFly instance is running.</h3>

        <p><a href="https://docs.wildfly.org">Documentation</a> | <a href="https://github.com/wildfly/quickstart">Quickstarts</a> | <a href="/console">Administration
            Console</a> </p>

        <p><a href="https://wildfly.org">WildFly Project</a> |
            <a href="https://community.jboss.org/en/wildfly">User Forum</a> |
            <a href="https://issues.jboss.org/browse/WFLY">Report an issue</a></p>
        <p class="logos"><a href="https://www.jboss.org"><img src="jbosscommunity_logo_hori_white.png" alt="JBoss and JBoss Community" width=
                "195" height="37" border="0"></a></p>

        <p class="note">To replace this page simply deploy your own war with / as its context path.<br />
            To disable it, remove the "welcome-content" handler for location / in the undertow subsystem.</p>
    </div>
</div>
</body>
</html>

cpusに設定した値も確認しておきます。

$ docker container inspect configuration-override-proxy-1 | grep NanoCpus
            "NanoCpus": 1000000000,


$ docker container inspect configuration-override-webapp-1 | grep NanoCpus
            "NanoCpus": 1000000000,

このコンテナ群は、1度停止・削除しておきます。

上書きするファイルを用意する

上書きするファイルは、こちらを用意。
ドキュメントには、ファイル名がcompose.override.yamlでも読まれるとは書かれていませんが、いけるのでは?と思って試してみます。

compose.override.yaml

services:
  proxy:
    ports:
      - "10080:80"

  webapp:
    deploy:
      resources:
        limits:
          cpus: '2'
        reservations:
          cpus: '2'

nginxの方はローカルにバインドするポートを追加し、WildFlyの方はCPU割当を増やしています。

確認。docker compose upの時に、特に引数は指定しません。

$ docker compose up

nginxのコンテナに対して、ローカルにバインドされているポートが増えています。

$ docker compose ps
NAME                              IMAGE                                        COMMAND                  SERVICE             CREATED             STATUS              PORTS
configuration-override-proxy-1    nginx:1.23.3                                 "/docker-entrypoint.…"   proxy               46 seconds ago      Up 44 seconds       0.0.0.0:80->80/tcp, :::80->80/tcp, 0.0.0.0:10080->80/tcp, :::10080->80/tcp
configuration-override-webapp-1   quay.io/wildfly/wildfly:27.0.0.Final-jdk17   "/opt/jboss/wildfly/…"   webapp              46 seconds ago      Up 44 seconds       8080/tcp

CPU割当は、WildFlyのコンテナのみ増えていますね。

$ docker container inspect configuration-override-proxy-1 | grep NanoCpus
            "NanoCpus": 1000000000,


$ docker container inspect configuration-override-webapp-1 | grep NanoCpus
            "NanoCpus": 2000000000,

というわけで、compose.override.yamlファイルで構成を上書きできていることが確認できました。
この時、compose.override.yamlファイルについてはなにも指定していないことがポイントです。

ちなみに、-fオプションでファイルを明示的に指定すると上書き用のファイルは読まれなくなります。

$ docker compose -f compose.yaml up

確認。

$ docker compose ps
NAME                              IMAGE                                        COMMAND                  SERVICE             CREATED              STATUS              PORTS
configuration-override-proxy-1    nginx:1.23.3                                 "/docker-entrypoint.…"   proxy               About a minute ago   Up About a minute   0.0.0.0:80->80/tcp, :::80->80/tcp
configuration-override-webapp-1   quay.io/wildfly/wildfly:27.0.0.Final-jdk17   "/opt/jboss/wildfly/…"   webapp              About a minute ago   Up About a minute   8080/tcp


$ docker container inspect configuration-override-proxy-1 | grep NanoCpus
            "NanoCpus": 1000000000,


$ docker container inspect configuration-override-webapp-1 | grep NanoCpus
            "NanoCpus": 1000000000,

この場合は、それぞれのファイルを-fオプションで明示的に指定する必要があります。

$ docker compose -f compose.yaml -f compose.override.yaml up

これを利用することで、目的に応じて上書きするファイルを指定することが可能になります。

$ docker compose -f compose.yaml -f compose.prod.yaml up

また、オプションで指定するファイルの順番に上書きされていくようで、例えば以下のようにファイルの順番を逆にすると

$ docker compose -f compose.override.yaml -f compose.yaml up

マージされる値については結果は変わりませんが、単一の値については意味が変わってしまいます。

$ docker compose ps
NAME                              IMAGE                                        COMMAND                  SERVICE             CREATED             STATUS              PORTS
configuration-override-proxy-1    nginx:1.23.3                                 "/docker-entrypoint.…"   proxy               3 seconds ago       Up 1 second         0.0.0.0:80->80/tcp, :::80->80/tcp, 0.0.0.0:10080->80/tcp, :::10080->80/tcp
configuration-override-webapp-1   quay.io/wildfly/wildfly:27.0.0.Final-jdk17   "/opt/jboss/wildfly/…"   webapp              3 seconds ago       Up 1 second         8080/tcp


$ docker container inspect configuration-override-proxy-1 | grep NanoCpus
            "NanoCpus": 1000000000,


$ docker container inspect configuration-override-webapp-1 | grep NanoCpus
            "NanoCpus": 1000000000,

使い方はわかりましたね。

デフォルトの上書き用ファイル名のパターンは?

ちょっと気になったので調べてみました。

GitHubリポジトリを見てみたのですが、今ひとつわからなかったので

GitHub - docker/compose: Define and run multi-container applications with Docker

straceで見てみることにしました。

$ strace -f docker compose up 2>&1

以下のようなファイルを対象とするようです。

[pid 39678] newfstatat(AT_FDCWD, "/path/to/compose.yaml", {st_mode=S_IFREG|0664, st_size=121, ...}, 0) = 0
[pid 39678] newfstatat(AT_FDCWD, "/path/to/compose.yml", 0xc00039b898, 0) = -1 ENOENT (そのようなファイルやディレクトリ
はありません)
[pid 39678] newfstatat(AT_FDCWD, "/path/to/docker-compose.yml", 0xc00039b968, 0) = -1 ENOENT (そのようなファイルやディレクトリはありません)
[pid 39678] newfstatat(AT_FDCWD, "/path/to/docker-compose.yaml", 0xc00039ba38, 0) = -1 ENOENT (そのようなファイルやディ
レクトリはありません)
[pid 39678] newfstatat(AT_FDCWD, "/path/to/compose.override.yml", 0xc00039bb08, 0) = -1 ENOENT (そのようなファイルやディレクトリはありません)
[pid 39678] newfstatat(AT_FDCWD, "/path/to/compose.override.yaml", {st_mode=S_IFREG|0664, st_size=46, ...}, 0) = 0
[pid 39678] newfstatat(AT_FDCWD, "/path/to/docker-compose.override.yml", 0xc00039bca8, 0) = -1 ENOENT (そのようなファイ
ルやディレクトリはありません)
[pid 39678] newfstatat(AT_FDCWD, "/path/to/docker-compose.override.yaml", 0xc00039bd78, 0) = -1 ENOENT (そのようなファイ
ルやディレクトリはありません)

compose.override.ymlcompose.override.yamldocker-compose.override.ymldocker-compose.override.yamlの4つですね。

まとめ

Docker Composeで、構成ファイルを複数使って定義内容を上書きしていみました。

なんとなくそんな機能があることは知っていたのですが、今回ちゃんとドキュメントと動きを確認して、どのような機能かハッキリしたので
良かったかなとは思います。

とはいえ、あんまり濫用する機能でもないかなとも思うので、利用はほどほどにといったところかなという印象を持ちました。