CLOVER🍀

That was when it all began.

QuarkusでRESTEasy × Hibernate Validator(Bean Validation)

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

Quarkusで、Hibernate Validator(Bean Validation)を使ってみようかな、と。RESTEasyとの組み合わせですが。

こちらのドキュメントを見つつ、RESTEasyとHibernate Validatorで遊んでみます。

Quarkus - Validation with Hibernate Validator

Hibernate Validator(Bean Validation)のValidatorを直接扱う方法と、@Validアノテーションを使う方法があるようです。

環境

今回の環境は、こちら。

$ java --version
openjdk 11.0.9.1 2020-11-04
OpenJDK Runtime Environment (build 11.0.9.1+1-Ubuntu-0ubuntu1.20.04)
OpenJDK 64-Bit Server VM (build 11.0.9.1+1-Ubuntu-0ubuntu1.20.04, mixed mode, sharing)

$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.9.1, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-54-generic", arch: "amd64", family: "unix"

プロジェクトの作成

Quarkusプロジェクトを作成します。

$ mvn io.quarkus:quarkus-maven-plugin:1.9.2.Final:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=resteasy-bean-validation \
    -DprojectVersion=my-version=0.0.1-SNAPSHOT \
    -Dextensions="resteasy-mutiny, resteasy-jsonb, hibernate-validator"

Extensionとして、Hibernate Validatorを追加するにはhibernate-validatorを指定します。

あとはRESTEasyも追加するのですが、SmallRye MutinyとJSONを使おうということでresteasy-mutinyresteasy-jsonb
足しておきました。

作成したら、プロジェクト内へ移動。

$ cd resteasy-bean-validation

Maven依存関係は、こんな感じです。

  <dependencies>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-junit5</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.rest-assured</groupId>
      <artifactId>rest-assured</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-hibernate-validator</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-mutiny</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-jsonb</artifactId>
    </dependency>
  </dependencies>

プラグイン設定も見ておきます。generate-codeとか、増えてる気がします…。

  <build>
    <plugins>
      <plugin>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-maven-plugin</artifactId>
        <version>${quarkus-plugin.version}</version>
        <executions>
          <execution>
            <goals>
              <goal>generate-code</goal>
              <goal>generate-code-tests</goal>
              <goal>build</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>${compiler-plugin.version}</version>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>${surefire-plugin.version}</version>
        <configuration>
          <systemPropertyVariables>
            <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
            <maven.home>${maven.home}</maven.home>
          </systemPropertyVariables>
        </configuration>
      </plugin>
    </plugins>
  </build>

サンプルプログラム

JAX-RS(RESTEasy)を使って、Echoっぽいプログラムを書いてみます。リクエストとレスポンスはJSONで表現します。

リクエストで使うクラス。メッセージと、リプライ時の繰り返し回数を受け取ることにしましょう。
src/main/java/org/littlewings/quarkus/resteasyvalidation/RequestMessage.java

package org.littlewings.quarkus.resteasyvalidation;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

public class RequestMessage {
    @NotEmpty
    @Size(min = 5, max = 255)
    String message;

    @Min(1)
    @Max(10)
    int repeat;

    // getter/setterは省略
}

ここで、バリデーションのルールも付けておきます。

レスポンス用のクラス。repeatでの指定回数分だけ、メッセージを複製します。
src/main/java/org/littlewings/quarkus/resteasyvalidation/ResponseMessage.java

package org.littlewings.quarkus.resteasyvalidation;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ResponseMessage {
    List<String> messages;

    public static ResponseMessage create(String message, int repeat) {
        ResponseMessage responseMessage = new ResponseMessage();
        responseMessage.messages = IntStream.rangeClosed(1, repeat).mapToObj(i -> String.format("Reply: %s", message)).collect(Collectors.toList());

        return responseMessage;
    }

    public List<String> getMessages() {
        return messages;
    }
}

JAX-RSリソースクラスは、こんな感じで用意。
src/main/java/org/littlewings/quarkus/resteasyvalidation/EchoResource.java

package org.littlewings.quarkus.resteasyvalidation;

import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.inject.Inject;
import javax.validation.ConstraintViolation;
import javax.validation.Valid;
import javax.validation.Validator;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import io.smallrye.mutiny.Uni;

@Path("echo")
public class EchoResource {
    @Inject
    Validator validator;

    @POST
    @Path("manual")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Response> manual(RequestMessage requestMessage) {
        Set<ConstraintViolation<RequestMessage>> violations = validator.validate(requestMessage);

        if (violations.isEmpty()) {
            return Uni
                    .createFrom()
                    .item(
                            Response
                                    .ok()
                                    .entity(ResponseMessage.create(requestMessage.getMessage(), requestMessage.getRepeat()))
                                    .build()
                    );
        } else {
            return Uni
                    .createFrom()
                    .item(
                            Response
                                    .status(Response.Status.BAD_REQUEST)
                                    .entity(violations.stream().map(v -> Map.of(v.getPropertyPath().toString(), v.getMessage())).collect(Collectors.toList()))
                                    .build()
                    );
        }
    }

    @POST
    @Path("annotation")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<ResponseMessage> annotation(@Valid RequestMessage requestMessage) {
        return Uni.createFrom().item(ResponseMessage.create(requestMessage.getMessage(), requestMessage.getRepeat()));
    }
}

Hibernate Validator(Bean Validation)のValidatorをインジェクションして

    @Inject
    Validator validator;

APIを直接扱う方法(こちらは、バリデーションエラーの結果を自分で組み立てます)と

    @POST
    @Path("manual")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Response> manual(RequestMessage requestMessage) {
        Set<ConstraintViolation<RequestMessage>> violations = validator.validate(requestMessage);

        if (violations.isEmpty()) {
            return Uni
                    .createFrom()
                    .item(
                            Response
                                    .ok()
                                    .entity(ResponseMessage.create(requestMessage.getMessage(), requestMessage.getRepeat()))
                                    .build()
                    );
        } else {
            return Uni
                    .createFrom()
                    .item(
                            Response
                                    .status(Response.Status.BAD_REQUEST)
                                    .entity(violations.stream().map(v -> Map.of(v.getPropertyPath().toString(), v.getMessage())).collect(Collectors.toList()))
                                    .build()
                    );
        }
    }

@Validアノテーションを付与して、バリデーションの駆動はRESTEasy側に任せる方法、それぞれやってみます。

    @POST
    @Path("annotation")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<ResponseMessage> annotation(@Valid RequestMessage requestMessage) {
        return Uni.createFrom().item(ResponseMessage.create(requestMessage.getMessage(), requestMessage.getRepeat()));
    }

確認

パッケージングして

$ mvn package

起動。

$ java -jar target/resteasy-bean-validation-my-version\=0.0.1-SNAPSHOT-runner.jar

まずは、Validatorを直接扱う方から確認してみましょう。バリデーションNGとなる場合。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/echo/manual -d '{"message": "", "repeat": 200 }'
HTTP/1.1 400 Bad Request
Content-Length: 172
Content-Type: application/json

[{"message":"5 から 255 の間のサイズにしてください"},{"message":"空要素は許可されていません"},{"repeat":"10 以下の値にしてください"}]



$ curl -s -XPOST -H 'Content-Type: application/json' localhost:8080/echo/manual -d '{"message": "", "repeat": 200 }' | jq
[
  {
    "repeat": "10 以下の値にしてください"
  },
  {
    "message": "5 から 255 の間のサイズにしてください"
  },
  {
    "message": "空要素は許可されていません"
  }
]

OKな場合。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/echo/manual -d '{"message": "Hello, RESTEasy & Bean Validation!!", "repeat": 3 }'
HTTP/1.1 200 OK
Content-Length: 149
Content-Type: application/json

{"messages":["Reply: Hello, RESTEasy & Bean Validation!!","Reply: Hello, RESTEasy & Bean Validation!!","Reply: Hello, RESTEasy & Bean Validation!!"]}


$ curl -s -XPOST -H 'Content-Type: application/json' localhost:8080/echo/manual -d '{"message": "Hello, RESTEasy & Bean Validation!!", "repeat": 3 }' | jq
{
  "messages": [
    "Reply: Hello, RESTEasy & Bean Validation!!",
    "Reply: Hello, RESTEasy & Bean Validation!!",
    "Reply: Hello, RESTEasy & Bean Validation!!"
  ]
}

続いて、@Validアノテーションを使う場合。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/echo/annotation -d '{"message": "", "repeat": 200 }'
HTTP/1.1 400 Bad Request
Content-Length: 520
validation-exception: true
Content-Type: application/json

{"classViolations":[],"parameterViolations":[{"constraintType":"PARAMETER","message":"空要素は許可されていません","path":"annotation.requestMessage.message","value":""},{"constraintType":"PARAMETER","message":"5 から 255 の間のサイズにしてください","path":"annotation.requestMessage.message","value":""},{"constraintType":"PARAMETER","message":"10 以下の値にしてください","path":"annotation.requestMessage.repeat","value":"200"}],"propertyViolations":[],"returnValueViolations":[]}


$ curl -s -XPOST -H 'Content-Type: application/json' localhost:8080/echo/annotation -d '{"message": "", "repeat": 200 }' | jq
{
  "classViolations": [],
  "parameterViolations": [
    {
      "constraintType": "PARAMETER",
      "message": "10 以下の値にしてください",
      "path": "annotation.requestMessage.repeat",
      "value": "200"
    },
    {
      "constraintType": "PARAMETER",
      "message": "空要素は許可されていません",
      "path": "annotation.requestMessage.message",
      "value": ""
    },
    {
      "constraintType": "PARAMETER",
      "message": "5 から 255 の間のサイズにしてください",
      "path": "annotation.requestMessage.message",
      "value": ""
    }
  ],
  "propertyViolations": [],
  "returnValueViolations": []
}

なんか、異様に詳細な情報が返ってきますね。

素のRESTEasyの時は、なにも返ってこなかった気がしますが。

JAX-RS(RESTEasy)とBean Validationを合わせて使う - CLOVER🍀

ドキュメントを見てもこんなことが書いてあるので、なにかあるんでしょうね。

If a validation error is triggered, a violation report is generated and serialized as JSON as our end point produces a JSON output. It can be extracted and manipulated to display a proper error message.

Quarkus - Validation with Hibernate Validator

OKな場合。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/echo/annotation -d '{"message": "Hello, RESTEasy & Bean Validation!!", "repeat": 3 }'
HTTP/1.1 200 OK
Content-Length: 149
Content-Type: application/json

{"messages":["Reply: Hello, RESTEasy & Bean Validation!!","Reply: Hello, RESTEasy & Bean Validation!!","Reply: Hello, RESTEasy & Bean Validation!!"]}


$ curl -s -XPOST -H 'Content-Type: application/json' localhost:8080/echo/annotation -d '{"message": "Hello, RESTEasy & Bean Validation!!", "repeat": 3 }' | jq
{
  "messages": [
    "Reply: Hello, RESTEasy & Bean Validation!!",
    "Reply: Hello, RESTEasy & Bean Validation!!",
    "Reply: Hello, RESTEasy & Bean Validation!!"
  ]
}

よさそうです。

@Validを使ってた時のレスポンスを組み立てているのは?

@Validアノテーションを使った時に、誰がレスポンスを組み立てているのか?は気になるところですね。

こちらです。

https://github.com/quarkusio/quarkus/blob/1.9.2.Final/extensions/hibernate-validator/runtime/src/main/java/io/quarkus/hibernate/validator/runtime/jaxrs/ResteasyViolationExceptionMapper.java

https://github.com/quarkusio/quarkus/blob/1.9.2.Final/extensions/hibernate-validator/runtime/src/main/resources/META-INF/services/javax.ws.rs.ext.Providers

ValidationException向けのExceptionMapperが用意されています。

そういえば、ResteasyViolationExceptionはいつの間にかConstraintViolationExceptionのサブクラスになったんですね。

public abstract class ResteasyViolationException extends ConstraintViolationException

https://github.com/resteasy/Resteasy/blob/4.5.8.Final/resteasy-core-spi/src/main/java/org/jboss/resteasy/api/validation/ResteasyViolationException.java

以前はValidationExceptionのサブクラスでしたが、ConstraintViolationExceptionのサブクラスではなかったのでやや特別扱いが
必要だったのに。

https://github.com/resteasy/Resteasy/blob/3.0.8.Final/jaxrs/providers/resteasy-validator-provider-11/src/main/java/org/jboss/resteasy/api/validation/ResteasyViolationException.java

レスポンスに使われているのは、こちらのViolationReportですね。

https://github.com/resteasy/Resteasy/blob/4.5.8.Final/resteasy-core-spi/src/main/java/org/jboss/resteasy/api/validation/ViolationReport.java

https://github.com/quarkusio/quarkus/blob/1.9.2.Final/extensions/hibernate-validator/runtime/src/main/java/io/quarkus/hibernate/validator/runtime/jaxrs/ResteasyViolationExceptionMapper.java#L56-L72

ここで使われているResteasyConstraintViolationは、ResteasyViolationException内でConstraintViolationを変換したものです。

https://github.com/resteasy/Resteasy/blob/4.5.8.Final/resteasy-core-spi/src/main/java/org/jboss/resteasy/api/validation/ResteasyConstraintViolation.java

というわけで、こういうレスポンスは誰が組み立てているか、という話でした。

{
  "classViolations": [],
  "parameterViolations": [
    {
      "constraintType": "PARAMETER",
      "message": "10 以下の値にしてください",
      "path": "annotation.requestMessage.repeat",
      "value": "200"
    },
    {
      "constraintType": "PARAMETER",
      "message": "空要素は許可されていません",
      "path": "annotation.requestMessage.message",
      "value": ""
    },
    {
      "constraintType": "PARAMETER",
      "message": "5 から 255 の間のサイズにしてください",
      "path": "annotation.requestMessage.message",
      "value": ""
    }
  ],
  "propertyViolations": [],
  "returnValueViolations": []
}

ExceptionMapperを書く

最後に、自分でもExceptionMapperを書いてみましょう。

こんな感じで作成。
src/main/java/org/littlewings/quarkus/resteasyvalidation/MyViolationExceptionMapper.java

package org.littlewings.quarkus.resteasyvalidation;

import java.util.Map;
import java.util.stream.Collectors;
import javax.validation.ConstraintDeclarationException;
import javax.validation.ConstraintDefinitionException;
import javax.validation.GroupDefinitionException;
import javax.validation.ValidationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;

import org.jboss.resteasy.api.validation.ResteasyViolationException;

@Provider
public class MyViolationExceptionMapper implements ExceptionMapper<ValidationException> {
    @Override
    public Response toResponse(ValidationException exception) {
        if (exception instanceof ConstraintDefinitionException) {
            return Response.serverError().type(MediaType.APPLICATION_JSON).entity(Map.of("definition_error", exception.getMessage())).build();
        }
        if (exception instanceof ConstraintDeclarationException) {
            return Response.serverError().type(MediaType.APPLICATION_JSON).entity(Map.of("declaration_error", exception.getMessage())).build();
        }
        if (exception instanceof GroupDefinitionException) {
            return Response.serverError().type(MediaType.APPLICATION_JSON).entity(Map.of("group_definition_error", exception.getMessage())).build();
        } else if (exception instanceof ResteasyViolationException) {
            ResteasyViolationException ex = (ResteasyViolationException) exception;

            //return Response.status(Response.Status.BAD_REQUEST).entity(new ViolationReport(ex)).build();

            return Response
                    .status(Response.Status.BAD_REQUEST)
                    .entity(ex.getViolations().stream().map(v -> Map.of(v.getPath(), v.getMessage())).collect(Collectors.toList()))
                    .build();
        }

        return Response.serverError().type(MediaType.APPLICATION_JSON).entity(Map.of("unknown_error", exception.getMessage())).build();
    }
}

ValidationExceptionを型パラメーターに取ったExceptionMapperインターフェースを実装し、@Providerアノテーション
付与しておくことがポイントです。

@Provider
public class MyViolationExceptionMapper implements ExceptionMapper<ValidationException> {

このあたりは定義ミス系なので置いておいて

        if (exception instanceof ConstraintDefinitionException) {
            return Response.serverError().type(MediaType.APPLICATION_JSON).entity(Map.of("definition_error", exception.getMessage())).build();
        }
        if (exception instanceof ConstraintDeclarationException) {
            return Response.serverError().type(MediaType.APPLICATION_JSON).entity(Map.of("declaration_error", exception.getMessage())).build();
        }
        if (exception instanceof GroupDefinitionException) {
            return Response.serverError().type(MediaType.APPLICATION_JSON).entity(Map.of("group_definition_error", exception.getMessage())).build();

バリデーションエラーを扱っているのは、ここですね。

        } else if (exception instanceof ResteasyViolationException) {
            ResteasyViolationException ex = (ResteasyViolationException) exception;

            //return Response.status(Response.Status.BAD_REQUEST).entity(new ViolationReport(ex)).build();

            return Response
                    .status(Response.Status.BAD_REQUEST)
                    .entity(ex.getViolations().stream().map(v -> Map.of(v.getPath(), v.getMessage())).collect(Collectors.toList()))
                    .build();
        }

コメントアウトしている箇所を使うと、ResteasyViolationExceptionMapperが返しているレスポンスとほぼ同じになります。

では、確認してみましょう。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/echo/annotation -d '{"message": "", "repeat": 200 }'
HTTP/1.1 400 Bad Request
Content-Length: 250
Content-Type: application/json

[{"annotation.requestMessage.repeat":"10 以下の値にしてください"},{"annotation.requestMessage.message":"5 から 255 の間のサイズにしてください"},{"annotation.requestMessage.message":"空要素は許可されていません"}]


$ curl -s -XPOST -H 'Content-Type: application/json' localhost:8080/echo/annotation -d '{"message": "", "repeat": 200 }' | jq
[
  {
    "annotation.requestMessage.message": "5 から 255 の間のサイズにしてください"
  },
  {
    "annotation.requestMessage.repeat": "10 以下の値にしてください"
  },
  {
    "annotation.requestMessage.message": "空要素は許可されていません"
  }
]

作成したExceptionMapperに差し替えられたことを、確認できました。

OKな場合。

$ curl -s -XPOST -H 'Content-Type: application/json' localhost:8080/echo/annotation -d '{"message": "Hello, RESTEasy & Bean Validation!!", "repeat": 3 }' | jq
{
  "messages": [
    "Reply: Hello, RESTEasy & Bean Validation!!",
    "Reply: Hello, RESTEasy & Bean Validation!!",
    "Reply: Hello, RESTEasy & Bean Validation!!"
  ]
}

まとめ

Quarkusで、RESTEasyとHibernate Validator(Bean Validation)を使ってみました。

@Validアノテーションを使った時に、どんなレスポンスが返ってくるかドキュメントには書かれていなかったので、このあたりの
動作やソースコードの確認もしておいたので、今後もこれで覚えていられそうです。