0
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-07-18

はじめに

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

関連記事の一覧(予定)

リポジトリ

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

入力値を検証する

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

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

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);
    }
}

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

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

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

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

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

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

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

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;
        }
    }
}

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

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

次は異常系の例です。

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 を定義します。

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

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); // 追加
    }
}

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

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

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

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

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();
    }
}
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); // 追加
    }
}

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

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)

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

参考


  1. 以前の記事 で登録した AddCharsetUtf8ToResponseFilter を有効にしていると、デフォルトの plain/text レスポンスを生成できないので注意。 

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

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0