はじめに
Tomcat + Jersey という構成の Web アプリケーションの動作を理解するために、Tomcat Embed を使って実験をしていきます。
いまどきこんな構成で開発を始めることは少ないかと思いますが、レガシーソフトウェアと戦う人たちの助けになれば幸いです。
関連記事の一覧(予定)
- 埋め込みTomcatでJerseyを動かしてみる
- HK2でDIしてみる
- BeanValidationで入力値検証してみる ← イマココ
- TomcatとJerseyでトランザクション管理してみる
リポジトリ
なお、リポジトリは application/json
のレスポンスを返すように実装されていますが、本記事では読みやすさのため plain/text
のレスポンスを返す実装に書き直しています。
入力値を検証する
入力値検証機能を有効にするには、ValidationFeature
を登録します。
デフォルトでは入力値検証のエラー内容をクライアント側に返さない設定になっています。今回は入力値検証の結果をクライアント側で確認したいので、 BV_SEND_ERROR_IN_RESPONSE
を true
にします。1
@ApplicationPath("app")
public class AppConfig extends ResourceConfig {
public AppConfig() {
packages(getClass().getPackage().getName());
register(ValidationFeature.class);
property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true);
}
}
例として、身長 (cm) と体重 (kg) から BMI を計算するリソースを用意します。身長と体重どちらも「ゼロを含まない正の数」と制約するため、 @Positive
で注釈します。
なお、身長はプロパティ、体重はメソッドの引数と分かれて定義されていますが、ここでは気にしないでください。
...
@Path("validation")
public class ValidationResource {
@QueryParam("height")
@Positive
private int height;
@GET
public double getBmi(@QueryParam("weight") @Positive int weight) {
return 10000.0 * weight / (height * height);
}
}
アプリケーションを起動して、アクセスしてみましょう。まずは正常系を確認します。
curl "localhost:8080/app/validation?height=160&weight=60"
# 23.4375
BMI を計算できました。次は異常系を確認します。身長・体重の両方に 0 を入力してみましょう。
curl "localhost:8080/app/validation?height=0&weight=0"
0 より大きな値にしてください (path = ValidationResource.getBmi.arg0, invalidValue = 0)
0 より大きな値にしてください (path = ValidationResource.height, invalidValue = 0)
いずれの値も、 0 より大きい値を要求されていることがわかります。2
制約に違反したパラメータを推定する
さきほど得られたレスポンスのうち、どのパラメータが制約に違反したかは path
に示されています。
身長は ValidationResource.height
とプロパティ名が保持されていますが、体重は ValidationResource.getBmi.arg0
のように引数名の情報が失われてしまいました。
他の指定方法ではどうなるでしょうか。実装を変更してみます。3
@Path("validation")
public class ValidationResource {
@QueryParam("height")
@Positive
private int height;
@GET
@Path("{id}")
public String get(@Valid @BeanParam ParamBean bean, @QueryParam("weight") @Positive int weight) {
var bmi = 10000.0 * weight / (height * height);
return String.format("%s(%d) #%s %dcm %dkg BMI %.1f", bean.name, bean.age, bean.id.value, height, weight, bmi);
}
public static class IdNumber {
@Pattern(regexp = "[0-9a-fA-F]{8}")
private final String value;
public IdNumber(String value) {
this.value = value;
}
}
private static class ParamBean {
@QueryParam("name")
@NotEmpty
private String name;
@Min(18)
private int age;
@Valid
@PathParam("id")
private IdNumber id;
private ParamBean(@QueryParam("age") int age) {
this.age = age;
}
}
}
アプリケーションを再起動してアクセスしてみましょう。以下は正常系の例です。
curl "http://localhost:8080/app/validation/1234CDEF?name=John&age=25&height=160&weight=60"
# John(25) #1234CDEF 160cm 60kg BMI 23.4
次は異常系の例です。
curl "localhost:8080/app/validation/a"
# 0 より大きな値にしてください (path = ValidationResource.get.arg1, invalidValue = 0)
# 18 以上の値にしてください (path = ValidationResource.get.arg0.age, invalidValue = 0)
# 正規表現 "[0-9a-fA-F]{8}" にマッチさせてください (path = ValidationResource.get.arg0.id.value, invalidValue = a)
# 0 より大きな値にしてください (path = ValidationResource.height, invalidValue = 0)
# 空要素は許可されていません (path = ValidationResource.get.arg0.name, invalidValue = null)
いずれも制約アノテーションをもつプロパティまたは引数までのパスが示されますが、引数の名前は保持されないことがわかります。
引数のパラメータ名を特定する
失われてしまった引数名のかわりに、任意の名前を与えることができます。引数に付与された @QueryParam
の値で置換してみましょう。
ValidationConfig
を返す ContextResolver
を定義します。
public class ValidationConfigurationContextResolver implements ContextResolver<ValidationConfig> {
@Override
public ValidationConfig getContext(final Class<?> type) {
final ValidationConfig config = new ValidationConfig();
config.parameterNameProvider(new ParameterNameProvider() {
@Override
public List<String> getParameterNames(final Constructor<?> constructor) {
return convertToAnnotatedName(constructor.getParameters());
}
@Override
public List<String> getParameterNames(final Method method) {
return convertToAnnotatedName(method.getParameters());
}
private List<String> convertToAnnotatedName(Parameter[] parameters) {
var ret = new ArrayList<String>();
for (var param : parameters) {
var queryParam = param.getAnnotation(QueryParam.class);
if (queryParam != null) {
ret.add(String.format("[%s]", queryParam.value()));
continue;
}
ret.add(param.getName());
}
return ret;
}
});
return config;
}
}
作成した ValidationConfigurationContextResolver
をアプリケーションに登録します。
@ApplicationPath("app")
public class AppConfig extends ResourceConfig {
public AppConfig() {
packages(getClass().getPackage().getName());
register(ValidationFeature.class);
property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true);
register(ValidationConfigurationContextResolver.class); // 追加
}
}
アプリケーションを再起動して、異常系のレスポンスを確認してみましょう。
curl "localhost:8080/app/validation/a"
# 0 より大きな値にしてください (path = ValidationResource.get.[weight], invalidValue = 0)
# 正規表現 "[0-9a-fA-F]{8}" にマッチさせてください (path = ValidationResource.get.arg0.id.value, invalidValue = a)
# 18 以上の値にしてください (path = ValidationResource.get.arg0.age, invalidValue = 0)
# 0 より大きな値にしてください (path = ValidationResource.height, invalidValue = 0)
# 空要素は許可されていません (path = ValidationResource.get.arg0.name, invalidValue = null)
weight
のパスが ValidationResource.get.[weight]
となりました。 もとは arg1
となっていた箇所が @QueryParam
の値である weight
で置換されたことがわかります。
さらに以下の方策をとれば、制約に違反したパラメータ名をクライアントに返却できるようになります。4
- 制約アノテーションを付与したプロパティの名前をパラメータ名と一致させるコーディング規約を設ける
- パスのうち、最初に見つかったプロパティ、または末尾のノードの名前を返す
本記事のコードはすでに 1 を満たしています。 2 の実装例は以下です。
public class PlainTextConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException exception) {
return Response.status(Status.BAD_REQUEST).entity(
exception.getConstraintViolations().stream().map(
v -> String.format("%s (parameter = %s, invalidValue = %s)",
v.getMessage(),
StreamSupport.stream(v.getPropertyPath().spliterator(), false)
.reduce((a, b) -> a != null && a.getKind() == ElementKind.PROPERTY ? a : b)
.map(Node::getName).orElse(null),
v.getInvalidValue()))
.collect(Collectors.joining("\n"))).build();
}
}
@ApplicationPath("app")
public class AppConfig extends ResourceConfig {
public AppConfig() {
packages(getClass().getPackage().getName());
register(ValidationFeature.class);
property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true);
register(ValidationConfigurationContextResolver.class);
register(PlainTextConstraintViolationExceptionMapper.class); // 追加
}
}
アプリケーションを再起動して、異常系のレスポンスを確認してみましょう。
curl "localhost:8080/app/validation/a"
# 18 以上の値にしてください (parameter = age, invalidValue = 0)
# 0 より大きな値にしてください (parameter = [weight], invalidValue = 0)
# 空要素は許可されていません (parameter = name, invalidValue = null)
# 0 より大きな値にしてください (parameter = height, invalidValue = 0)
# 正規表現 "[0-9a-fA-F]{8}" にマッチさせてください (parameter = id, invalidValue = a)
これでパラメータ名だけを抽出できました。