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 です。
リクエスト受信から 422 レスポンス返却までの流れを図にすると、以下のようになります。
つまり、400 ではなく 422 を返したい場合は、MethodArgumentNotValidException が発生したときの処理を自分で用意する必要があります(図の右下、GlobalExceptionHandler の部分です)。
例外発生時の処理はどこに書くべきか
最初に思いつきやすいのは、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 は自分でControllerから呼び出すものではない、という点です。
Springが例外の発生を検知し、@RestControllerAdvice と @ExceptionHandler を見て、自動的に該当メソッドを呼び出します。
GlobalExceptionHandler というクラス名自体は必須ではありません。
Springが見ているのはクラス名ではなく、@RestControllerAdvice や @ExceptionHandler です。
@RestControllerAdviceと@ExceptionHandlerの理解
ここまでに登場した「Controller」「例外」「アノテーション」「ハンドラクラス」を、いったん役割で整理します。
| 登場人物 | 役割 | 今回の用途 |
|---|---|---|
Controller |
リクエストを受けてビジネス処理を呼び出す |
/login を受ける |
MethodArgumentNotValidException |
@Valid 失敗時に Spring が投げる例外 |
400 から 422 に変換したい対象 |
@RestControllerAdvice |
複数 Controller に共通して効く処理を置くクラスにつける目印 | 例外処理を一箇所に集約する |
@ExceptionHandler |
特定の例外を処理するメソッドの目印 |
MethodArgumentNotValidException 用メソッドを指定 |
GlobalExceptionHandler |
上の2つのアノテーションを使った例外処理クラス(命名は任意) | 422 レスポンスを組み立てて返す |
以降、表の上から2つのアノテーションを個別に見ていきます。
@RestControllerAdviceとは何か
@RestControllerAdvice は、複数のControllerに共通して効く処理を書くための仕組みです。
今回の用途では、以下のように理解しました。
REST API用のController共通の例外処理置き場
Advice という言葉は分かりづらいですが、自分の理解では「Controller本体の外側から共通処理を差し込む仕組み」です。
@ExceptionHandlerとは何か
@ExceptionHandler は、特定の例外が発生したときに呼ばれるメソッドの目印です。
@ExceptionHandler(MethodArgumentNotValidException.class)
これは、以下の意味です。
MethodArgumentNotValidException が発生したら、このメソッドで処理する
実際に書いてみる
最初から完成形を書くと分かりづらかったので、以下の順番で進めました。
- まず422だけ返す
- 固定メッセージのJSONを返す
- fieldErrorsを固定値で返す
- exから最初の1件だけ取り出す
- stream().map().toList()で全件返す
ステップ1: 最初は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() は不要です。
ステップ2: 固定メッセージの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
ステップ3: フィールドエラーを構造に追加する
次に、どの項目がエラーになったかも返したくなりました。
そのため、フィールドエラー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>
→ フィールドエラー一覧
ステップ4: exから最初の1件を取り出す
次に、MethodArgumentNotValidException ex から実際のエラーを取り出しました。
var fieldError = ex.getBindingResult().getFieldErrors().get(0);
フィールド名は以下で取得できます。
fieldError.getField()
エラーメッセージは以下で取得できます。
fieldError.getDefaultMessage()
これを使うと、固定値ではなく、実際に発生したバリデーションエラーをレスポンスに入れられます。
ただし、この書き方では最初の1件しか返せません。
get(0)
→ エラー一覧の最初の1件だけ取得する
ステップ5: stream/map で全件返す
ログインIDとパスワードの両方が空の場合、バリデーションエラーは2件発生します。
そのため、最初の1件だけではなく、全件を返す必要があります。
最終的には、stream() と map() を使って全件変換しました。
var fieldErrors = ex.getBindingResult()
.getFieldErrors() // Spring が持っているエラー一覧を取得
.stream() // 1件ずつ処理できるストリームに変換
.map(fieldError -> new ValidationFieldError( // Spring の FieldError → 自作 DTO
fieldError.getField(),
fieldError.getDefaultMessage()))
.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: 全 Controller 共通の例外処理クラスとして Spring に認識させる
@RestControllerAdvice
public class GlobalExceptionHandler {
// レスポンス本体の DTO(学習用にハンドラ内に同居)
public record ValidationErrorResponse(
int status,
String message,
List<ValidationFieldError> fieldErrors) {
}
// フィールドエラー1件分の DTO
public record ValidationFieldError(
String field,
String message) {
}
// @Valid 失敗時に Spring が投げる MethodArgumentNotValidException をこのメソッドで処理する
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationError(MethodArgumentNotValidException ex) {
HttpStatus status = HttpStatus.UNPROCESSABLE_ENTITY;
// Spring のフィールドエラー一覧を、自作 DTO の List に変換
var fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fieldError -> new ValidationFieldError(
fieldError.getField(),
fieldError.getDefaultMessage()))
.toList();
ValidationErrorResponse response =
new ValidationErrorResponse(status.value(), "入力値が不正です", fieldErrors);
// body(...) を呼んだ時点でレスポンスが完成するため build() は不要
return ResponseEntity.status(status)
.body(response);
}
}
今回理解したこと
今回理解したことは以下です。
-
@Validの失敗時にはMethodArgumentNotValidExceptionが発生する - 400ではなく422を返したい場合、その例外を自分でハンドリングする必要がある
-
@ExceptionHandlerは、特定の例外を処理するメソッドの目印 -
@Validの失敗はControllerメソッドに入る前に起きる -
build()はボディなしでレスポンスを完成させる -
body(...)はボディありでレスポンスを完成させる - バリデーションエラーは複数件同時に発生する