AIによる要約
@RequestBodyは、HTTPリクエストのボディに入っているJSONをJavaオブジェクトへ変換して受け取るために使います。クエリ文字列やHTMLフォームを受けるためのものではありません。APIの入口ではRequest DTOとセットで考えると安全です。


Spring BootでAPIを作ると、@RequestBodyがよく出てきます。
利用者登録のAPIにJSONを送って400になる場合、Controllerの登録メソッドにブレークポイントを置くだけでは原因が見えません。このサンプルで、本文が読めないときと、読めたDTOが入力チェックで弾かれるときを切り分けます。
この記事のポイント
@RequestBodyはJSONなどのリクエストボディを受け取る- HTMLフォームやクエリ文字列とは受け取り方が違う
- APIではRequest DTOを用意してEntityと分ける
- 本文の読み取り失敗とDTOの入力チェックを区別する
実行用サンプル
正常なJSON、壊れた文法、型不一致、本文なし、DTOの入力チェックを比較します。 ソース全文、JUnitの検証コード、curlの実行ファイル、pom.xml、READMEを収録しています。JDK 17とMaven 3.9系で mvn test を実行します。Spring Boot 3.5.16のサンプルです。
@RequestBodyはリクエストボディを読む
@RequestBodyは、HTTPリクエストのボディに入っているデータをJavaオブジェクトへ変換します。Spring BootのAPIでは、JSONを受け取るときによく使います。
UserInputController.java の抜粋。JSON登録を受けるメソッド
@PostMapping(path = "/api/users", consumes = APPLICATION_JSON_VALUE)
public ResponseEntity<UserView> create(@Valid @RequestBody UserCreateRequest request) {
UserView user = userService.create(request.name(), request.email(), request.notificationLimit());
return ResponseEntity.created(URI.create("/api/users/" + user.id())).body(user);
}Spring MVCの HttpMessageConverter が本文を読み、Jacksonで UserCreateRequest に変換します。次に @Valid がDTOを検証し、ここまで通ると初めて create の本体からServiceが呼ばれます。
送信されるJSONの例
create-user.json の全文
{
"name": "Suzuki",
"email": "suzuki@example.com",
"notificationLimit": 5
}name、email、notificationLimit がJavaのrecordの各コンポーネントに対応します。展開先でアプリを起動し、下のJSONを送ると201と Location が返ります。このサンプルは設定で未知のプロパティをエラーにしているため、キーの綴り違いも確認できます。
UserCreateRequest.java の全文
package info.syachiku.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record UserCreateRequest(
@NotBlank String name,
@NotBlank @Email String email,
@NotNull @Min(1) Integer notificationLimit) {}サンプルを mvn spring-boot:run で起動し、別のターミナルで展開先へ移動して送ります。
locations.sh の抜粋。接続先の設定
base_url="${BASE_URL:-http://localhost:8080}"locations.sh の抜粋。正常なJSONの送信コマンド
# 本文にJSONを送る
curl -si "$base_url/api/users" \
-H 'Content-Type: application/json' \
--data-binary @requests/create-user.json@RequestParamとの違い
@RequestParamは、主にクエリ文字列やフォームの単発パラメータを受け取ります。JSONボディを受け取るためのものではありません。
同じ /api/users でも、GET /api/users?keyword=tanaka の keyword はURLのクエリ、POST /api/users の name はJSON本文です。ブラウザーの開発者ツールではQuery String ParametersとPayloadを見分けます。
たとえば本文が空のまま POST /api/users?name=Suzuki を送っても、クエリの name でDTOは作られません。JSON本文として指定します。
入力チェックは@Validと組み合わせる
APIでも入力チェックは必要です。Request DTOに制約を付け、Controllerで@Validを付けます。
先ほどの UserCreateRequest で @NotNull が上限の未指定を、@Min(1) が0以下を弾きます。上限の型を Integer にしたので、未指定と0を区別できます。
構文が正しく "notificationLimit":0 を送ると、0は Integer に変換できますが @Min(1) を満たしません。Spring MVCは MethodArgumentNotValidException を解決し、標準の処理でHTTP 400を返します。
Entityを直接受け取らない
APIでも、Entityを@RequestBodyで直接受けるのは避けた方がよいことが多いです。
UserService.java の抜粋。保存データの初期権限をサーバー側で決める
UserView create(String name, String email, int notificationLimit) {
long id = sequence.incrementAndGet();
StoredUser user = new StoredUser(id, name, email, notificationLimit, "MEMBER");
users.put(id, user);
return user.view();
}入力用DTOには、保存データの id や role を含めません。この例では、ServiceがIDと初期権限 MEMBER を決め、応答用DTOにも role を含めません。JSONへ "role":"ADMIN" を追加すると、サンプルの未知プロパティの設定により400になり、登録は呼ばれません。
400エラーになったときの確認ポイント
確認リスト
Content-Type: application/jsonを指定し、本文も送ったか- JSONの項目名とRequest DTOのフィールド名が合っているか
- この例の
notificationLimitに整数を渡したか - JSONの文法が壊れていないか
- 整数に変換できた値が
@Min(1)を満たすか
下の4リクエストはすべて400ですが、上から順に「文法」「型変換」「必須の本文」「DTOの制約」で止まります。Content-Type が異なり415が返る場合は、まず リクエストを受けるハンドラの条件 を確認します。
body-errors.sh の抜粋。登録APIへ異なる不正入力を送るコマンド
base_url="${BASE_URL:-http://localhost:8080}"
# 文法エラー。閉じ波括弧がない
curl -si "$base_url/api/users" \
-H 'Content-Type: application/json' \
--data-binary '{"name":"Suzuki","email":"suzuki@example.com","notificationLimit":5'
# 型変換エラー。manyをIntegerにできない
curl -si "$base_url/api/users" \
-H 'Content-Type: application/json' \
--data-binary '{"name":"Suzuki","email":"suzuki@example.com","notificationLimit":"many"}'
# 本文なし。Content-TypeはJSON
curl -si "$base_url/api/users" \
-H 'Content-Type: application/json' \
--data-binary ''
# 制約違反。Integerには変換できるが、1以上ではない
curl -si "$base_url/api/users" \
-H 'Content-Type: application/json' \
--data-binary '{"name":"Suzuki","email":"suzuki@example.com","notificationLimit":0}'| 送った内容 | Spring MVCが報告した例外 | 直す箇所 |
|---|---|---|
最後の } がない | HttpMessageNotReadableException | 閉じ波括弧を足す |
notificationLimit が "many" | HttpMessageNotReadableException | 数値の 5 を送る |
| JSONの指定はあるが本文が空 | HttpMessageNotReadableException | 必要な3項目をJSON本文に入れる |
notificationLimit が 0 | MethodArgumentNotValidException | 1以上の整数にする |
MockMvcでハンドラとして create が選ばれても、引数が確定しなければ本体に入らないことを確かめました。下は 0 のケースで、notificationLimit の Min エラーと登録Serviceの呼び出し0回を検証するテストです。
UserInputTest.java の抜粋。数値への変換後、制約で弾かれることを検証
@Test
void parsedJsonCanFailValidationBeforeCallingService() throws Exception {
var result = mvc.perform(post("/api/users").contentType(APPLICATION_JSON)
.content("""
{"name":"Suzuki","email":"suzuki@example.com","notificationLimit":0}
"""))
.andExpect(status().isBadRequest())
.andExpect(handler().methodName("create"))
.andReturn();
assertThat(result.getResolvedException()).isInstanceOfSatisfying(
MethodArgumentNotValidException.class, error ->
assertThat(error.getBindingResult().getFieldError("notificationLimit").getCode())
.isEqualTo("Min"));
assertThat(service.createCalls.get()).isZero();
}2記事の共通サンプル全体の実行結果です。
Tests run: 12, Failures: 0, Errors: 0, Skipped: 0この記事とあわせて読みたい
まとめ
@RequestBodyは、JSONなどのリクエストボディをJavaオブジェクトとして受け取るためのアノテーションです。
400になったら、まず本文が送られたか、次に文法と型、最後にDTOの制約を確かめます。HttpMessageNotReadableException と MethodArgumentNotValidException を区別すると、送信データのどこを直すべきか絞れます。



