1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Spring Bootで@Validのバリデーションエラーを400ではなく422で返すまでに理解したこと

1
Posted at

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 以降が前提。デフォルトで MethodArgumentNotValidException400 Bad Request として扱われる挙動はバージョンに依存します)
  • Java 17(記事中で recordvarStream.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) {
    // ログイン処理
}

このとき、リクエストの loginIdpassword が空だと、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 が起きたときの処理

実際に書いてみる

最初から完成形を書くと分かりづらかったので、以下の順番で進めました。

  1. まず422だけ返す
  2. 固定メッセージのJSONを返す
  3. fieldErrorsを固定値で返す
  4. exから最初の1件だけ取り出す
  5. 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件とは限らないからです。

たとえば、loginIdpassword の両方が空なら、エラーは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へ変換する

最終的なコード

最終的には、以下のようになりました。

GlobalExceptionHandler.java
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(...)はボディありでレスポンスを完成させる
  • バリデーションエラーは複数件同時に発生する

参考文献

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?