Spring DataのPageableに対するバリデーションって必要だよな〜

今回は、Spring Data提供の`Pageable`インタフェースが保持する値に対するバリデーション（入力チェック）を行う方法を紹介します。
Spring Dataが提供する`Pageable`を使用すると、検索対象のページ情報(ページ数、ページ内の表示件数)に加え、ソート条件（対象プロパティ・カラム、昇順・降順）をリクエストパラメータで指定できます。ソート条件を指定できるのは機能的には便利なのですが、使い方を間違えるとSQLインジェクションなどを引き起こす原因になりえます。こういったインジェクションを防ぐために必要になるのが・・・言わずもがな・・・バリデーションです。


# 動作検証バージョン

* Spring Boot 1.4.2.RELEASE
* Spring Framework 4.3.4.RELEASE
* Spring Data Commons 1.12.5.RELEASE
* Hibernate Validator 5.2.4.Final


# 実現方法

Controllerに実装したHandlerメソッドの引数に対して、Springが提供するMethod Validation(Bean Validationの仕組みを活用する)機能を利用してバリデーションを行います。

具体的には・・・こんな感じです。

```java
@GetMapping
Page<Todo> search(@AllowedSortProperties({"id", "title"}) Pageable pageable) {
    // ...
    return page;
}
```

Springが提供するMethod Validationは、Spring AOPの機能を利用して、メソッド呼び出しの引数や返り値に対するバリデーションをサポートしています。

# チェックしてみよう！！

ここでは、ソート条件の対象プロパティやカラムが許可リストにあるかチェックするバリデーションを適用します。

## 開発プロジェクトの作成

いつものとおり、「[SPRING INITIALIZR](https://start.spring.io/)」からSpring Bootの開発プロジェクトを作りましょう。その際、依存アーティファクトとして「Web」を選択してください。


## Spring Data Commonsの適用

`Pageable`が格納されているSpring Data Commonsを依存ライブラリに加えます。

```xml:pom.xml
<dependency>
	<groupId>org.springframework.data</groupId>
	<artifactId>spring-data-commons</artifactId>
</dependency>
```

Spring Data Commonsを依存ライブラリに追加すると、Spring BootのAutoConfigureの仕組みによって、`Pageable`をControllerのHandlerメソッドの引数に指定できるようにするための機能(`PageableHandlerMethodArgumentResolver`など)が有効化されます。

## ConstraintValidatorの作成

`Pageable`に対するチェックを行うための「制約アノテーション」と「`ConstraintValidator`の実装クラス」を作成します。

```java:Pageable用の制約アノテーション
package com.example;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Constraint(validatedBy = {AllowedSortPropertiesValidator.class})
@Target({ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
public @interface AllowedSortProperties {
    String message() default "{com.example.AllowedSortProperties.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String[] value();

    @Target({ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        AllowedSortProperties[] value();
    }

}
```

```java:Pageable用のConstraintValidatorの実装クラス
package com.example;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

public class AllowedSortPropertiesValidator implements ConstraintValidator<AllowedSortProperties, Pageable> {

    private Set<String> allowedSortProperties;

    @Override
    public void initialize(AllowedSortProperties constraintAnnotation) {
        this.allowedSortProperties = new HashSet<>(Arrays.asList(constraintAnnotation.value()));
    }

    @Override
    public boolean isValid(Pageable value, ConstraintValidatorContext context) {
        if (value == null || value.getSort() == null) {
            return true;
        }
        if (allowedSortProperties.isEmpty()) {
            return true;
        }
        for (Sort.Order order : value.getSort()) {
            if (!allowedSortProperties.contains(order.getProperty())) {
                return false;
            }
        }
        return true;
    }

}
```

## Method Validationの適用

ControllerのHandlerメソッドの呼び出しに対してMethod Validationを適用します。

```java
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

@SpringBootApplication
public class BeanValidationDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(BeanValidationDemoApplication.class, args);
    }

    @Bean // Bean ValidationのValidatorを生成するコンポーネントのBean定義
    static LocalValidatorFactoryBean localValidatorFactoryBean() {
        return new LocalValidatorFactoryBean();
    }

    @Bean // Method Validation(AOP)を適用するコンポーネントのBean定義
    static MethodValidationPostProcessor methodValidationPostProcessor(LocalValidatorFactoryBean localValidatorFactoryBean) {
        MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
        processor.setValidator(localValidatorFactoryBean);
        return processor;
    }

}
```

## Hanlderメソッドの作成

`Pageable`を受け取るHandler Methodを作成し、↑で作成した制約アノテーションを指定します。その際、Method Validationの対象にするためにクラスレベルに`@Validated`を付与する必要があります。

```java
package com.example;

import org.springframework.data.domain.Pageable;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Validated // これ追加しないとMethod Validationの対象にならない
@RestController
@RequestMapping("/todos")
public class TodoRestController {

    @GetMapping
    Pageable search(@AllowedSortProperties({"id", "title"}) Pageable pageable) {
        return pageable;
    }

}
```

本当なら検索処理を実行してドメインオブジェクトのリストを返却するところですが、ここでは受け取った`Pageable`をそのまま返却する実装になっています。（あくまで検証用のアプリなので・・・:sweat_smile:）

## 動かしてみる

Spring Bootアプリケーションを起動して、実際にアクセスしてみましょう。

```:許可リストにあるプロパティ・カラム名を指定した場合の動作
$ curl -D - http://localhost:8080/todos?sort=id
HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 17 Dec 2016 15:51:51 GMT

{"sort":[{"direction":"ASC","property":"id","ignoreCase":false,"nullHandling":"NATIVE","ascending":true}],"offset":0,"pageNumber":0,"pageSize":20}
```

```:許可リストにないプロパティ・カラム名を指定した場合の動作
$ curl -D - http://localhost:8080/todos?sort=createdAt
HTTP/1.1 500 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sat, 17 Dec 2016 15:53:47 GMT
Connection: close

{"timestamp":1481990027135,"status":500,"error":"Internal Server Error","exception":"javax.validation.ConstraintViolationException","message":"No message available","path":"/todos"}
```

うまく動いているみたいですが、許可リストにないプロパティ・カラム名を指定した場合のエラーが、500 Internal Server Error扱いになってしまっています。本来であれば400 Bad Requestにすべきでしょう。

# 例外ハンドリングを行おう！！

デフォルトの状態だと500 Internal Server Errorになってしまうので、Method Validationでエラーが発生した場合の例外ハンドリングを追加して、400 Bad Requestにしましょう。

```java
package com.example;

import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.ConstraintViolationException;

@Validated
@RestController
@RequestMapping("/todos")
public class TodoRestController {

    @GetMapping
    Pageable search(@AllowedSortProperties({"id", "title"}) Pageable pageable) {
        return pageable;
    }

    @ExceptionHandler // ConstraintViolationExceptionをハンドリングするメソッドを追加
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    String handleConstraintViolationException(ConstraintViolationException e) {
        return "Detect invalid parameter.";
    }

}
```

本来であればグローバルな例外ハンドラに実装すべきですが、ここではControllerの中に実装しています。またエラーレスポンスも簡易的なエラーメッセージを出力するだけの実装になっているので、要件に応じた実装にしてください。


```:例外ハンドリング追加後のエラーレスポンス
$ curl -D - http://localhost:8080/todos?sort=createdAt
HTTP/1.1 400 
Content-Type: text/plain;charset=UTF-8
Content-Length: 25
Date: Sat, 17 Dec 2016 16:02:29 GMT
Connection: close

Detect invalid parameter.
```

> **Note:**
>
> ちゃんと設計されたアプリケーションであれば、`Pageable`で管理する情報をユーザが手で入力することはないと思うので、エラーを検出した場合に親切なエラーレスポンスを返す必要はないと思います。（この手のエラーは、アプリケーションが提供している機能を使っていれば出ないはずなので・・・）

# まとめ

今回は、Spring Dataが提供する`Pageable`インタフェースに対してバリデーションを行う方法を紹介しました。Method Validationを使うのがベストプラクティスかわかりませんが、Spring MVCのバリデーション機能と同じような感覚でチェックできるので、現時点ではこの方法がオススメかな〜と思っています。

# 参考サイト

* http://terasolunaorg.github.io/guideline/5.2.0.RELEASE/ja/ArchitectureInDetail/WebApplicationDetail/Validation.html#method-validation