[Jersey]BeanValidationで入力値検証してみる

# はじめに

Tomcat + Jersey という構成の Web アプリケーションの動作を理解するために、[Tomcat Embed](https://mvnrepository.com/artifact/org.apache.tomcat.embed) を使って実験をしていきます。
いまどきこんな構成で開発を始めることは少ないかと思いますが、レガシーソフトウェアと戦う人たちの助けになれば幸いです。

## 関連記事の一覧（予定）

- [埋め込みTomcatでJerseyを動かしてみる](https://qiita.com/atti/items/fa3843f75c4bd669059a)
- [HK2でDIしてみる](https://qiita.com/atti/items/3f6f43c5168323344427)
- BeanValidationで入力値検証してみる ← イマココ
- [TomcatとJerseyでトランザクション管理してみる](https://qiita.com/atti/items/4d682f01cf6018172936)

## リポジトリ

https://github.com/at6ue/tomcat_embed-jersey

なお、リポジトリは `application/json` のレスポンスを返すように実装されていますが、本記事では読みやすさのため `plain/text` のレスポンスを返す実装に書き直しています。

# 入力値を検証する

入力値検証機能を有効にするには、`ValidationFeature` を登録します。
デフォルトでは入力値検証のエラー内容をクライアント側に返さない設定になっています。今回は入力値検証の結果をクライアント側で確認したいので、 `BV_SEND_ERROR_IN_RESPONSE` を `true` にします。[^1]

[^1]: [以前の記事](https://qiita.com/atti/items/fa3843f75c4bd669059a#%E3%83%95%E3%82%A3%E3%83%AB%E3%82%BF%E3%83%BC%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B) で登録した `AddCharsetUtf8ToResponseFilter` を有効にしていると、デフォルトの `plain/text` レスポンスを生成できないので注意。

```java:AppConfig.java
@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` で注釈します。
なお、身長はプロパティ、体重はメソッドの引数と分かれて定義されていますが、ここでは気にしないでください。

```java:ValidationResource.java
...
@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);
    }
}
```

アプリケーションを起動して、アクセスしてみましょう。まずは正常系を確認します。

```bash:Terminal
curl "localhost:8080/app/validation?height=160&weight=60"
# 23.4375
```

BMI を計算できました。次は異常系を確認します。身長・体重の両方に 0 を入力してみましょう。

```bash:Terminal
curl "localhost:8080/app/validation?height=0&weight=0"
0 より大きな値にしてください (path = ValidationResource.getBmi.arg0, invalidValue = 0)
0 より大きな値にしてください (path = ValidationResource.height, invalidValue = 0)
```

いずれの値も、 0 より大きい値を要求されていることがわかります。[^2]

[^2]: 日本語のメッセージが返る場合、ブラウザからアクセスすると文字化けします。環境によってはコンソールでも文字化けするかもしれません。

# 制約に違反したパラメータを推定する

さきほど得られたレスポンスのうち、どのパラメータが制約に違反したかは `path` に示されています。
身長は `ValidationResource.height` とプロパティ名が保持されていますが、体重は `ValidationResource.getBmi.arg0` のように引数名の情報が失われてしまいました。

他の指定方法ではどうなるでしょうか。実装を変更してみます。[^3]

[^3]: `id` や `age` のように、オブジェクトの一部を検証する場合は引数ではなくプロパティに制約アノテーションを付与する必要があります。

```java:ValidationResource.java
@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;
        }
    }
}
```

アプリケーションを再起動してアクセスしてみましょう。以下は正常系の例です。

```bash:Terminal
curl "http://localhost:8080/app/validation/1234CDEF?name=John&age=25&height=160&weight=60"
# John(25) #1234CDEF 160cm 60kg BMI 23.4
```

次は異常系の例です。

```bash:Terminal
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` を定義します。

```java:ValidationConfigurationContextResolver.java
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` をアプリケーションに登録します。

```java:AppConfig.java
@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); // 追加
    }
}
```

アプリケーションを再起動して、異常系のレスポンスを確認してみましょう。

```bash:Terminal
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]

[^4]: あらゆる実装でうまくいくかは検証できていません。

1. 制約アノテーションを付与したプロパティの名前をパラメータ名と一致させるコーディング規約を設ける
2. パスのうち、最初に見つかったプロパティ、または末尾のノードの名前を返す

本記事のコードはすでに 1 を満たしています。 2 の実装例は以下です。

```java:PlainTextConstraintViolationExceptionMapper
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();
    }
}
```

```java:AppConfig.java
@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); // 追加
    }
}
```

アプリケーションを再起動して、異常系のレスポンスを確認してみましょう。

```bash:Terminal
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)
```

これでパラメータ名だけを抽出できました。

# 参考

- [Chapter 19. Bean Validation Support](https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/bean-validation.html)
- [JavaEE使い方メモ（Bean Validation）](https://qiita.com/opengl-8080/items/3926fbde5469c0b330c2)