Spring BootでREST APIを作っていると、@Validによるバリデーションエラー時に、デフォルトでは 400 Bad Request が返ります。
今回、自分のAPIでは、入力値の不備を 422 Unprocessable Entity として返したかったため、@RestControllerAdvice と @ExceptionHandler を使って共通例外処理を作りました。
ただ、最初は以下のような点がよく分かっていませんでした。
- そもそもバリデーションエラー時に何が起きているのか?
-
MethodArgumentNotValidExceptionとは何か? - 例外発生時の処理はどこに書くのか?
-
GlobalExceptionHandlerは何のために作るのか? -
GlobalExceptionHandlerは Controller から呼ぶのか? -
@RestControllerAdviceの Advice とは何か? -
@ExceptionHandlerは何をしているのか? -
ResponseEntity<Void>のVoidは何か? -
build()とbody()は何が違うのか? - 複数のバリデーションエラーはどう返すのか?
この記事では、初学者として自分が詰まった点を振り返りながら、@Valid のバリデーションエラーを 422 で返すまでの流れを整理します。
動作環境
- Spring Boot 3.x(少なくとも 2.3 以降が前提。デフォルトで
MethodArgumentNotValidExceptionが400 Bad Requestとして扱われる挙動はバージョンに依存します) - Java 17(記事中で
record・var・Stream.toList()を使用しているため、最低でも Java 16 以降が必要)
前提:何を実現したかったのか
実装中のログインAPIで、リクエストDTOに以下のようなバリデーションを付けていました。
public record LoginRequest(
@NotBlank
String loginId,
@NotBlank
String password
) {
}
このとき、以下のようなリクエストを送るとします。
{
"loginId": "",
"password": ""
}
この場合、@NotBlank によるバリデーションが働き、エラーになります。
ただし、Spring Bootのデフォルトでは 400 Bad Request が返ります。
今回の目的は、デフォルトの 400 Bad Request ではなく、以下のような 422 レスポンスを返すことです。
{
"status": 422,
"message": "入力値が不正です",
"fieldErrors": [
{
"field": "loginId",
"message": "must not be blank"
},
{
"field": "password",
"message": "must not be blank"
}
]
}
まず知るべきこと:バリデーションエラー時に何が起きるのか
最初、自分は「バリデーションエラーになったら、Controllerの中で何か処理を書くのかな?」くらいに考えていました。
しかし、@Valid @RequestBody のバリデーションは、Controllerのメソッド本体に入る前に実行されます。
たとえば、Controllerが以下のようになっていたとします。
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
// ログイン処理
}
このとき、リクエストの loginId や password が空だと、Controllerメソッドの中に入る前にバリデーションエラーになります。
そのときに発生する例外が、MethodArgumentNotValidException です。
流れとしては以下です。
POST /login
↓
JSONをLoginRequestに変換
↓
@Validで検証
↓
@NotBlankに違反
↓
MethodArgumentNotValidException が発生
↓
Springのデフォルト処理では400が返る
つまり、400 ではなく 422 を返したい場合は、MethodArgumentNotValidException が発生したときの処理を自分で用意する必要があります。
例外発生時の処理はどこに書くべきか
最初に思いつきやすいのは、Controller内で try-catch する方法です。
しかし、@Valid @RequestBody のバリデーションは Controllerメソッドの中に入る前に行われるため、以下のように書いても基本的には捕まえられません。
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
try {
// ここに来る前に@Validが失敗する
} catch (...) {
// ここでは捕まえられない
}
}
また、仮に各Controllerで例外処理を書けたとしても、同じようなバリデーションエラー処理が複数のControllerに散らばります。
たとえば、将来的には以下のようなControllerでも入力値エラーが起こります。
AuthController
UserController
RequestController
それぞれに同じようなエラーレスポンス処理を書くと、保守しづらくなります。
そこで、バリデーションエラーのような共通処理は、グローバルな例外ハンドラーにまとめます。
AuthController
UserController
RequestController
↓ 例外が起きたら
GlobalExceptionHandler
GlobalExceptionHandlerとは何か
GlobalExceptionHandler は、アプリ全体で共通して使う例外処理クラスです。
今回でいうと、複数のControllerで発生しうる MethodArgumentNotValidException をまとめて受け止め、400 ではなく 422 のレスポンスへ変換するために作ります。
つまり、今回の GlobalExceptionHandler の役割は以下です。
@Validのバリデーション失敗
↓
MethodArgumentNotValidException が発生
↓
GlobalExceptionHandler が受け取る
↓
422のレスポンスを作って返す
ここで重要なのは、GlobalExceptionHandler は自分でControllerから呼び出すものではない、という点です。
Springが例外の発生を検知し、@RestControllerAdvice と @ExceptionHandler を見て、自動的に該当メソッドを呼び出します。
GlobalExceptionHandler というクラス名自体は必須ではありません。
Springが見ているのはクラス名ではなく、@RestControllerAdvice や @ExceptionHandler です。
@RestControllerAdviceと@ExceptionHandlerの理解
@RestControllerAdviceとは何か
@RestControllerAdvice は、複数のControllerに共通して効く処理を書くための仕組みです。
今回の用途では、以下のように理解しました。
REST API用のController共通の例外処理置き場
Advice という言葉は分かりづらいですが、自分の理解では「Controller本体の外側から共通処理を差し込む仕組み」です。
@ExceptionHandlerとは何か
@ExceptionHandler は、特定の例外が発生したときに呼ばれるメソッドの目印です。
@ExceptionHandler(MethodArgumentNotValidException.class)
これは、以下の意味です。
MethodArgumentNotValidException が発生したら、このメソッドで処理する
つまり、今回作る処理は以下のようになります。
@RestControllerAdvice
→ 複数Controllerに共通して効く例外処理クラス
@ExceptionHandler(MethodArgumentNotValidException.class)
→ MethodArgumentNotValidException が起きたときの処理
実際に書いてみる
最初から完成形を書くと分かりづらかったので、以下の順番で進めました。
- まず422だけ返す
- 固定メッセージのJSONを返す
- fieldErrorsを固定値で返す
- exから最初の1件だけ取り出す
- stream().map().toList()で全件返す
最初は422だけ返す
まずは、レスポンスボディなしで 422 だけ返すところから始めました。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Void> handleValidationError(MethodArgumentNotValidException ex) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
}
この時点では、レスポンスボディはありません。
HTTP Status: 422
Body: なし
ResponseEntity<Void>とは何か
ResponseEntity は、HTTPレスポンス全体を表すための型です。
HTTPレスポンスには、主に以下があります。
ステータスコード
ヘッダー
ボディ
ResponseEntity<Void> は、以下の意味です。
ボディを持たないHTTPレスポンス
ここでの Void は、レスポンスボディがないことを型として表すために使っています。
build()とbody()の違い
最初は、build() は毎回最後に付けるものだと思っていました。
しかし、そうではありません。
build() は、ボディなしでレスポンスを完成させるメソッドです。
// ボディなし
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.build();
一方、body(...) は、ボディありでレスポンスを完成させるメソッドです。
// ボディあり
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(response);
body(...) を呼んだ時点でレスポンスは完成するので、その後に build() は不要です。
固定メッセージのJSONを返す
次に、固定メッセージのJSONを返すようにしました。
まず、エラーレスポンス用の record を作ります。
public record ValidationErrorResponse(
int status,
String message
) {
}
そして、ResponseEntity<Void> を ResponseEntity<ValidationErrorResponse> に変えます。
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationError(MethodArgumentNotValidException ex) {
ValidationErrorResponse response = new ValidationErrorResponse(422, "入力値が不正です");
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(response);
}
これで、以下のようなJSONが返ります。
{
"status": 422,
"message": "入力値が不正です"
}
DTOは別ファイルに切り出すべきか
今回は学習用として、GlobalExceptionHandler の中に record を書いています。
小さい実装で、この例外ハンドラ内でしか使わないなら、これでも問題ないと思います。
ただし、今後以下のようになったら、別ファイルに切り出した方がよさそうです。
複数の例外で同じエラーレスポンスを使う
OpenAPIのschemaと対応させたい
テストでも型として扱いたい
レスポンス形式が大きくなってきた
その場合は、たとえば以下のようなパッケージに置くのが自然だと思います。
com.yoshida.orgflow.common.exception.dto
フィールドごとのエラーも返したい
次に、どの項目がエラーになったかも返したくなりました。
そのため、フィールドエラー1件分を表す record を作りました。
public record ValidationFieldError(
String field,
String message
) {
}
そして、レスポンス側に fieldErrors を追加します。
public record ValidationErrorResponse(
int status,
String message,
List<ValidationFieldError> fieldErrors
) {
}
ここで List にしている理由は、バリデーションエラーは1件とは限らないからです。
たとえば、loginId と password の両方が空なら、エラーは2件になります。
{
"fieldErrors": [
{
"field": "loginId",
"message": "must not be blank"
},
{
"field": "password",
"message": "must not be blank"
}
]
}
最初は固定値でfieldErrorsを返した
まずは固定値で、1件分の fieldErrors を返しました。
List<ValidationFieldError> fieldErrors = List.of(
new ValidationFieldError("loginId", "入力値が不正です")
);
この時点では、実際のバリデーション結果ではなく、手動で1件分のリストを作っているだけです。
ここで理解したのは、ValidationFieldError は1件分のエラーであり、List<ValidationFieldError> がエラー一覧だということです。
ValidationFieldError
→ フィールドエラー1件分
List<ValidationFieldError>
→ フィールドエラー一覧
exから最初の1件を取り出す
次に、MethodArgumentNotValidException ex から実際のエラーを取り出しました。
var fieldError = ex.getBindingResult().getFieldErrors().get(0);
フィールド名は以下で取得できます。
fieldError.getField()
エラーメッセージは以下で取得できます。
fieldError.getDefaultMessage()
これを使うと、固定値ではなく、実際に発生したバリデーションエラーをレスポンスに入れられます。
ただし、この書き方では最初の1件しか返せません。
get(0)
→ エラー一覧の最初の1件だけ取得する
複数件のエラーを返す
ログインIDとパスワードの両方が空の場合、バリデーションエラーは2件発生します。
そのため、最初の1件だけではなく、全件を返す必要があります。
最終的には、stream() と map() を使って全件変換しました。
var fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fieldError -> new ValidationFieldError(
fieldError.getField(),
fieldError.getDefaultMessage()))
.toList();
ここでやっていることは以下です。
getFieldErrors()
→ Springが持っているフィールドエラー一覧を取得する
stream()
→ 一覧を1件ずつ処理できる形にする
map(...)
→ SpringのFieldErrorを、自作のValidationFieldErrorに変換する
toList()
→ 変換後の結果をListにする
最初は、stream().map(...).toList() の後に、さらに List.of(...) しようとして混乱しました。
しかし、toList() の時点で、すでに List<ValidationFieldError> は完成しています。
List.of(new ValidationFieldError(...))
→ 手動で1件分のListを作る
stream().map(...).toList()
→ Springが持っている複数件のエラーを、自作DTOのListへ変換する
最終的なコード
最終的には、以下のようになりました。
package com.yoshida.orgflow.common.exception;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
public record ValidationErrorResponse(
int status,
String message,
List<ValidationFieldError> fieldErrors) {
}
public record ValidationFieldError(
String field,
String message) {
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationError(MethodArgumentNotValidException ex) {
HttpStatus status = HttpStatus.UNPROCESSABLE_ENTITY;
var fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fieldError -> new ValidationFieldError(
fieldError.getField(),
fieldError.getDefaultMessage()))
.toList();
ValidationErrorResponse response =
new ValidationErrorResponse(status.value(), "入力値が不正です", fieldErrors);
return ResponseEntity.status(status)
.body(response);
}
}
今回理解したこと
今回理解したことは以下です。
-
@Validの失敗時にはMethodArgumentNotValidExceptionが発生する - 400ではなく422を返したい場合、その例外を自分でハンドリングする必要がある
-
@ExceptionHandlerは、特定の例外を処理するメソッドの目印 -
@Validの失敗はControllerメソッドに入る前に起きる -
build()はボディなしでレスポンスを完成させる -
body(...)はボディありでレスポンスを完成させる - バリデーションエラーは複数件同時に発生する