これは、なにをしたくて書いたもの?
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-mutiny
とresteasy-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
アノテーションを使った時に、誰がレスポンスを組み立てているのか?は気になるところですね。
こちらです。
ValidationException
向けのExceptionMapper
が用意されています。
そういえば、ResteasyViolationException
はいつの間にかConstraintViolationException
のサブクラスになったんですね。
public abstract class ResteasyViolationException extends ConstraintViolationException
以前はValidationException
のサブクラスでしたが、ConstraintViolationException
のサブクラスではなかったのでやや特別扱いが
必要だったのに。
レスポンスに使われているのは、こちらのViolationReport
ですね。
ここで使われているResteasyConstraintViolation
は、ResteasyViolationException
内でConstraintViolation
を変換したものです。
というわけで、こういうレスポンスは誰が組み立てているか、という話でした。
{ "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
アノテーションを使った時に、どんなレスポンスが返ってくるかドキュメントには書かれていなかったので、このあたりの
動作やソースコードの確認もしておいたので、今後もこれで覚えていられそうです。