Spring BindingResultをJSONで取得

概要

SpringはControllerでValidationをした後、有効でない値が存在するとき、Error(BindingResult)にその内容を盛り込んで、JSP、FreeMarkerなどのView Template Engineでエラー内容をMessageSourceにグローバル化して表示できるように対応しています。しかし、そのようなグローバルメッセージをJSONで応答して表示させようとする場合、便利な方法がなかなか見当たりません。そこで、JSONでグローバル化されたエラー内容を取得できるように、Viewの内容をカスタマイズする方法について紹介したいと思います。

デフォルト動作のソースコード

依存性
Spring-Boot:2.0.0.RELEASE
lombok:1.16.18AdderController.java
以下はPOST /add?a=1&b=2を要請したとき、有効性チェック後に{“result”:3}を返却するソースです。

@RestController
@RequestMapping("/add")
public class AdderController {
    private final Validator adderRequestValidator;
    @Autowired
    public AdderController(@Qualifier("adderRequestValidator") Validator adderRequestValidator) {
        this.adderRequestValidator = adderRequestValidator;
    }
    @PostMapping
    public AdderResult add(AdderRequest request, BindingResult bindingResult) throws BindException {
        adderRequestValidator.validate(request, bindingResult);
        if (bindingResult.hasErrors()) {
            throw new BindException(bindingResult);
        }
        return new AdderResult(request.getA() + request.getB());
    }
}

AdderRequestValidator.java
検証のために実装してみました。abが空の場合、field.requiredコード値でerrorsfiledErrorsにエラー内容が追加されます。

@Component
public class AdderRequestValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return AdderRequest.class.isAssignableFrom(clazz);
    }
    @Override
    public void validate(Object o, Errors errors) {
        AdderRequest request = AdderRequest.class.cast(o);
        if (request.getA() == null) {
            errors.rejectValue("a", "field.required");
        }
        if (request.getB() == null) {
            errors.rejectValue("b", "field.required");
        }
    }
}

error.xml
field.requiredの内容を解析して、グローバル化するXMLプロパティを定義しました。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
    <entry key="field.required.adderRequest.a">aを入力してください</entry>
    <entry key="field.required.adderRequest.b">bを入力してください</entry>
</properties>

無効な要請があったとき

何のカスタマイズもせずにPOST /add?a=1にリクエストを送ると、BindExceptionの内容をViewで応答します。内容は以下の通りです。

{
  "timestamp": 1519659376474,
  "status": 400,
  "error": "Bad Request",
  "exception": "org.springframework.validation.BindException",
  "errors":[{
    "codes":[
      "field.required.adderRequest.b",
      "field.required.b",
      "field.required.java.lang.Integer",
      "field.required"
    ],
    "arguments": null,
    "defaultMessage": null,
    "objectName": "adderRequest",
    "field": "b",
    "rejectedValue": null,
    "bindingFailure": false,
    "code": "field.required"
  }],
  "message": "Validation failed for object='adderRequest'. Error count: 1",
  "path": "/add"
}

応答内容にerror.xmlで定義したメッセージの内容が降りてきません。(defaultMessageを定義すると、その値は満たされますが、グローバル化が適用されません。)

クライアントで言語関連リソースを持ち、コードを適切に対照して読み込めていればラッキーです。しかし、クライアントが1つでない場合、グローバル処理するロジックに重複が発生するので、サーバーを終了させるのが効果的でしょう。

サーバーからグローバルメッセージに変更

応答モデルの定義
モデルは任意のデータ構造で構いません。それぞれのクライアントの特徴に合わせて開発しよう。ここで以下のようなJSONを出力するように定義します。

{
  "errors":[{
   "objectName": "adderRequest",
    "field": "b",
    "code": "field.required",
    "message": "bを入力してください"
  }]
}

ValidationResult

エラーのリスト(errors)を持ち、必要に応じて共通の属性を追加定義できます。

@Value
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ValidationResult {
   private List<FieldErrorDetail> errors;

   public static ValidationResult create(Errors errors, MessageSource messageSource, Locale locale) {
      List<FieldErrorDetail> details =
            errors.getFieldErrors()
               .stream()
               .map(error -> FieldErrorDetail.create(error, messageSource, locale))
               .collect(Collectors.toList());
      return new ValidationResult(details);
   }
}

FieldErrorDetail
FieldErrorの詳細を記述するクラスです。

@Value
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class FieldErrorDetail {
   private String objectName;
   private String field;
   private String code;
   private String message;

   public static FieldErrorDetail create(FieldError fieldError, MessageSource messageSource, Locale locale) {
      return new FieldErrorDetail(
         fieldError.getObjectName(),
         fieldError.getField(),
         fieldError.getCode(),
         messageSource.getMessage(fieldError, locale)); // この部分がポイント
   }
}

messageSource.getMessage(MessageSourceResolvable, Locale)を使ってXMLに定義したグローバルメッセージを読み込むことができます。これが可能な理由は、FieldErrorMessageSourceResolvableを実装しているからです。

ExceptionHandler定義

APIサーバーであれば@RestControllerAdviceなどを使って、コントローラのアドバイスに登録させるのも良い方法です。

@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationResult handleBindException(BindException bindException, Locale locale) {
   return ValidationResult.create(bindException, messageSource, locale);
}

応答

{
  "errors":[{
   "objectName": "adderRequest",
    "field": "b",
    "code": "field.required",
    "message": "bを入力してください"
  }]
}

結論

基本的に、SpringはBindExceptionに対してExceptionの内容をJSONで表示するだけです。コード値は取得できますが、結局コードに対応するグローバルメッセージをクライアントから解析しなければなりません。しかし複数のクライアントに対応するためには、サーバーからのグローバルコードを解釈して与える方がよいでしょう。
グローバルメッセージを取得するためには、BindExceptionFieldErrorを読み込んでmessageSourceを使う必要があります。上記のカスタマイズを経て、はじめて希望するグローバルメッセージを読み込むことができるという点が少し残念です。

出典:https://github.com/supawer0728/spring-bindingresult-json

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop