今回は、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の仕組みを活用する)機能を利用してバリデーションを行います。
具体的には・・・こんな感じです。
@GetMapping
Page<Todo> search(@AllowedSortProperties({"id", "title"}) Pageable pageable) {
// ...
return page;
}
Springが提供するMethod Validationは、Spring AOPの機能を利用して、メソッド呼び出しの引数や返り値に対するバリデーションをサポートしています。
チェックしてみよう!!
ここでは、ソート条件の対象プロパティやカラムが許可リストにあるかチェックするバリデーションを適用します。
開発プロジェクトの作成
いつものとおり、「SPRING INITIALIZR」からSpring Bootの開発プロジェクトを作りましょう。その際、依存アーティファクトとして「Web」を選択してください。
Spring Data Commonsの適用
Pageable
が格納されているSpring Data Commonsを依存ライブラリに加えます。
<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
の実装クラス」を作成します。
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();
}
}
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を適用します。
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
を付与する必要があります。
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
をそのまま返却する実装になっています。(あくまで検証用のアプリなので・・・)
動かしてみる
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にしましょう。
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のバリデーション機能と同じような感覚でチェックできるので、現時点ではこの方法がオススメかな〜と思っています。