Spring Boot 04:從 JSON 到 Service,讀懂驗證與錯誤回應
API 回傳 400,Controller 的斷點卻完全沒停住,不一定是 mapping 壞掉。JSON 可能解析失敗,或 @Valid 已經在呼叫方法之前拒絕資料。這一課把「請求進得來」與「業務接受這個輸入」拆開觀察。
POST /api/tickets
Content-Type: application/json
{"title":"Learn Boot"}
DispatcherServlet → JSON conversion → @Valid
→ Controller → Service → Store
← status + headers + JSON先定義輸入,不讓資料庫類別決定 HTTP 格式
// CreateTicket.java
package com.keeponfirst.bootlab;
import jakarta.validation.constraints.NotBlank;
public record CreateTicket(@NotBlank String title) {}CreateTicket 是輸入 DTO,只有 title。@NotBlank 拒絕 null、空字串與空白文字。HTTP 輸入、業務物件與資料庫模型可以不同;即使本例的 Ticket 也很小,仍把輸入分開,避免將來新增內部欄位就不小心變成 API 可寫欄位。
@PostMapping("/tickets")
public ResponseEntity<Ticket> create(@Valid @RequestBody CreateTicket request) {
Ticket ticket = service.createBatch(List.of(request.title())).getFirst();
return ResponseEntity.created(URI.create("/api/tickets/" + ticket.id()))
.body(ticket);
}這是 TicketController 的方法片段,完整 import 與類別在下載檔。@RequestBody 要求把本文轉成 CreateTicket,@Valid 再要求驗證。接著 Controller 呼叫 Service,把成功結果轉成 201 與 Location。Location 指到新工單,讓呼叫端可以接著 GET;這是本例自行定義的契約,不是每個 POST 都會自動如此。
錯誤格式由程式明確決定
// ApiErrors.java
package com.keeponfirst.bootlab;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiErrors {
@ExceptionHandler(MissingTicket.class)
public ResponseEntity<Map<String, String>> missing(MissingTicket error) {
return ResponseEntity.status(404).body(Map.of("code", "TICKET_NOT_FOUND"));
}
@ExceptionHandler(BadTitle.class)
public ResponseEntity<Map<String, String>> badTitle(BadTitle error) {
return ResponseEntity.status(422).body(Map.of("code", "INVALID_TITLE"));
}
@ExceptionHandler({MethodArgumentNotValidException.class,
HttpMessageNotReadableException.class})
public ResponseEntity<Map<String, String>> badRequest(Exception error) {
return ResponseEntity.badRequest().body(Map.of("code", "INVALID_REQUEST"));
}
}本例選擇簡單的 {"code":"..."}。缺工單是 404;無法解析或欄位驗證失敗是 400;Service 拒絕標題長度是 422。它們都是設計選擇,不能把 HTTP status 全部改成 200 再要求前端猜 body。Spring 也支援 ProblemDetail;是否採用,要看既有客戶端契約與一致的例外處理設計。
| 輸入 | status | 觀察點 |
|---|---|---|
| 合法 title | 201 | id、trim 後 title、Location |
| title 只有空白 | 400 | INVALID_REQUEST、沒有寫入 |
| JSON 只有左大括號 | 400 | INVALID_REQUEST |
| 不存在的 id | 404 | TICKET_NOT_FOUND |
| 81 個 ASCII 字元的 title | 422 | INVALID_TITLE、沒有寫入 |
動手:一個輸入只改一件事
curl -i -X POST http://127.0.0.1:8080/api/tickets \
-H 'Content-Type: application/json' -d '{"title":" Learn Boot "}'
curl -i -X POST http://127.0.0.1:8080/api/tickets \
-H 'Content-Type: application/json' -d '{"title":" "}'
curl -i -X POST http://127.0.0.1:8080/api/tickets \
-H 'Content-Type: application/json' -d '{'預測這三次會在哪一層停止。第一個輸入會到 Service,再由它 trim 後寫入;第二個在 Bean Validation 被拒絕;第三個在 JSON 轉換就失敗。保留 status、header 與 body,不只截一張「有收到資料」的畫面。標題長度目前用 Java String.length 計算 UTF-16 code unit,不能直接當成所有語言的可見字數。
為什麼有些拒絕不會經過 ApiErrors?
本範例沒有 Spring Security。正式應用的 Security filter 可能在 MVC 前回傳 401 或 403;這不是把 @ExceptionHandler 加進去就一定能攔到的範圍。CORS 也不等於登入驗證。將來加入安全控制時,要再列未登入、權限不足、session 到期與 CSRF 案例,不能靠移除 filter 讓測試變綠。
答案與工作對照:CXF 換成 MVC,哪些要重比?
即使呼叫同一個 Service,參數解析、日期格式、null 欄位、Content-Type 與錯誤格式都可能改變。升級比對時,固定請求只是起點,還要核對呼叫端實際依賴的回應契約。這份練習的價值,是你能說出差異發生在哪一層,而不是把所有 HTTP 問題都丟回業務程式。
下載本系列完整工單 API 範例(ZIP)下一課:05:資料庫與交易回滾