HTTP 400と項目名・理由を示すJSON応答の図。入力エラーを契約として返す

実装・エラー解決

Spring BootでバリデーションエラーをJSONで返す方法|RestControllerAdviceの実装例

APIで空の件名を送ると400になる。ところが画面側では、どの欄を直せばよいか分からない。ステータスコードだけ合っていても、入力画面を作る側には足りません。

私なら、エラー応答を利用者が修正できる項目名とメッセージだけに絞った契約にします。スタックトレース、例外クラス名、送信された秘密値を返す必要はありません。ここでは Java 21、Spring Boot 3.5系、Spring MVC の @Valid @RequestBody を例にします。

応答形式を先に決める

APIのエラー応答に必要な要素。HTTP 400:入力の問題と示す。項目名:どこを直すか示す。理由:利用者向けに簡潔に。テスト:応答形を固定する
APIのエラー応答に必要な要素。APIの応答も契約として設計

件名を必須にした、架空のタスク登録APIです。空文字なら次の応答にします。

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "code": "VALIDATION_ERROR",
  "fields": {
    "title": "件名を入力してください"
  }
}

code は画面側が処理を分けるための固定値です。fields のキーは入力項目の名前で、今回の例では title です。日本語メッセージは表示用であり、プログラムの分岐条件には使いません。同じ項目に複数制約が違反したときは、ここでは最初に取得した1件を表示します。どの制約が先になるかへ依存せず、表示の優先順位が必要なら明示的に決めてください。全件必要なAPIなら Map<String, List<String>> に契約を変えます。

DTOとコントローラーを作る

spring-boot-starter-validation を依存関係に入れます。@NotBlank は null・空文字・空白だけの文字列を拒否します。

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

record TaskInput(@NotBlank(message = "件名を入力してください") String title) {}

@RestController
class TasksController {
    @PostMapping("/tasks")
    ResponseEntity<Void> create(@Valid @RequestBody TaskInput input) {
        // ここへ到達した入力を保存する
        return ResponseEntity.noContent().build();
    }
}

この形の引数検証に失敗すると MethodArgumentNotValidException が発生し、Spring MVCは標準で400を返します。ここから自分たちのJSON形式へ変換する部分を追加します。

RestControllerAdviceで項目別エラーへ変換する

import java.util.LinkedHashMap;
import java.util.Map;
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;

record ApiValidationError(String code, Map<String, String> fields) {}

@RestControllerAdvice
class ApiValidationAdvice {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ApiValidationError> onInvalid(MethodArgumentNotValidException ex) {
        Map<String, String> fields = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                fields.putIfAbsent(error.getField(),
                        error.getDefaultMessage() == null
                                ? "入力を確認してください" : error.getDefaultMessage()));
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ApiValidationError("VALIDATION_ERROR", fields));
    }
}

getRejectedValue() や ex.getMessage() をそのまま返していないのがポイントです。パスワードや個人情報の入力値、内部実装をレスポンスへ混ぜないためです。クラス単位の相関チェックも扱うAPIなら、getGlobalErrors() を別の errors 配列へ変換するか、制約側で修正すべき項目へ違反を紐付けます。

MockMvcで契約を確かめる

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(TasksController.class)
@Import(ApiValidationAdvice.class)
class TasksControllerTest {
    @Autowired MockMvc mvc;

    @Test
    void 空の件名は項目別エラーを返す() throws Exception {
        mvc.perform(post("/tasks")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"\"}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
            .andExpect(jsonPath("$.fields.title").value("件名を入力してください"));
    }
}

このテストは「エラーになった」だけでなく、利用側が頼るフィールド名とコードを固定します。メッセージを多言語化するなら、文言そのもののテストは言語を指定して分けます。

この例が扱わないエラーを混同しない

不正なJSON、日付への変換失敗、認証失敗、業務上の重複は、同じ MethodArgumentNotValidException とは限りません。またコントローラー引数に @Min などの制約を直接付けた場合、Spring MVC 6.1以降のメソッド検証では HandlerMethodValidationException が発生し得ます。そこまで共通形式にするなら、別途その例外の応答も設計してテストします。

まずは自分たちのAPIが受け取るDTOで、どの失敗を400として返すかを一つずつ固定してください。例外を何でもまとめて400にすると、サーバー側の不具合まで「入力のせい」に見えてしまいます。

Spring BootのController、入力、Serviceのつながりを通して学び直すなら『作って学ぶ Spring Boot入門』が候補です。Java、HTML/CSS、基本SQLの知識が前提で、この記事のJSON形式の出典として紹介しているわけではありません。出版社の内容紹介を確認し、必要なら脱初心者の本棚(Amazon)から探せます。Amazonのリンクにはアフィリエイトを含みます。

参考資料

-実装・エラー解決
-, ,