Handler method trong Spring MVC không bao giờ tự đọc request thô. Nó chỉ khai báo parameter — một Long id, một boolean dryRun, một record — và trước khi method chạy, Spring tìm từng giá trị ở đúng phần của HTTP request, convert từ chuỗi sang type đã khai báo rồi truyền vào. Annotation trên mỗi parameter cho biết đó là phần nào: path, query string, một header, một cookie hay body. Giá trị trả về đi theo chiều ngược lại, và ResponseEntity là cách để method tự đặt status code và header chứ không chỉ mỗi body.
Bài này đi qua từng annotation và cho thấy chuyện gì thực sự xảy ra khi giá trị bị thiếu hoặc không convert được. Chính những trường hợp đó quyết định client nhận về lỗi 400 mà nó tự sửa được, hay lỗi 500 chỉ thẳng vào code của bạn, và có một trường hợp đã không còn chạy như các tutorial cũ mô tả. Cuối bài, catalogue sản phẩm được nâng cấp để trả về 201 kèm header Location, 404 cho sản phẩm không tồn tại và 204 khi xóa.
![]()
Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, Tomcat 11.0.24 embedded) và Gradle 9.7.1, trên project sinh bởi Spring Initializr với dependencies=web. Application được khởi động bằng java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8117, nên các lệnh đều dùng port 8117, và mọi status line, header, dòng log đều copy từ chính những lần chạy đó.
Catalogue sản phẩm làm điểm xuất phát
Chương 3 xây một API duy nhất: catalogue sản phẩm dưới /api/products. Sản phẩm nằm trong một ConcurrentHashMap, AtomicLong lo cấp id, vì database tới Chương 4 mới xuất hiện. Model gồm ba type nhỏ trong com.example.demo.product:
package com.example.demo.product;
public enum Category {
BOOKS, ELECTRONICS, GROCERY
}package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String name, BigDecimal price, Category category) {
}package com.example.demo.product;
import java.math.BigDecimal;
public record ProductRequest(String name, BigDecimal price, Category category) {
}ProductRequest là thứ client gửi lên để tạo sản phẩm: mọi field trừ id, vì id do server cấp. Thiết kế type cho request và response một cách bài bản là chủ đề của bài tiếp theo.
Controller xuất phát từ bộ khung CRUD của bài trước, với bốn thay đổi cho bài này: sản phẩm có thêm category, thứ mà bộ lọc danh sách ở cuối bài cần; create đọc một ProductRequest, type không có field id, thay vì Product; constructor lưu sẵn ba sản phẩm để ví dụ nào cũng có dữ liệu; còn replace và update được bỏ ra vì bài này không đụng tới chúng:
package com.example.demo.product;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
public ProductController() {
add(new ProductRequest("Clean Code", new BigDecimal("32.50"), Category.BOOKS));
add(new ProductRequest("Mechanical Keyboard", new BigDecimal("89.90"), Category.ELECTRONICS));
add(new ProductRequest("Arabica Coffee Beans", new BigDecimal("14.20"), Category.GROCERY));
}
@GetMapping
public List<Product> findAll() {
return products.values().stream()
.sorted(Comparator.comparing(Product::id))
.toList();
}
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return products.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@RequestBody ProductRequest request) {
return add(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
}
private Product add(ProductRequest request) {
long id = sequence.incrementAndGet();
Product product = new Product(id, request.name(), request.price(), request.category());
products.put(id, product);
return product;
}
}Giống phiên bản trước, nó trả về status cố định dù chuyện gì xảy ra. Hỏi một sản phẩm không tồn tại:
curl -i http://localhost:8117/api/products/99HTTP/1.1 200
Content-Length: 0
Date: Sat, 12 Sep 2026 07:01:33 GMTfindById trả về null, không có body nào để ghi nên status vẫn là 200. Xóa chính sản phẩm không tồn tại đó:
curl -i -X DELETE http://localhost:8117/api/products/99HTTP/1.1 204
Date: Sat, 12 Sep 2026 07:01:33 GMT@ResponseStatus(HttpStatus.NO_CONTENT) hứa trước 204 khi method còn chưa chạy, nên nó không thể báo rằng chẳng có gì bị xóa. Cả hai sẽ được sửa ở cuối bài. Trước hết là chuyện dữ liệu đi vào các method này bằng cách nào.
Mỗi phần của HTTP request được bind vào đâu
HTTP request mang dữ liệu ở năm chỗ, và Spring MVC có một annotation cho mỗi chỗ:
| Phần của request | Ví dụ | Annotation |
|---|---|---|
| Path | /api/products/42 | @PathVariable |
| Query string, và các field của body dạng form | ?category=BOOKS | @RequestParam |
| Header | X-Request-Id: 7f3a9c | @RequestHeader |
| Cookie | Cookie: theme=dark | @CookieValue |
| Body | {"name":"Desk Lamp"} | @RequestBody |
Để xem bốn trong số đó cùng làm việc trong một lần gọi, thêm một controller dùng tạm, trả về đúng những gì nó nhận được:
package com.example.demo.echo;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.product.ProductRequest;
@RestController
public class EchoController {
public record Bound(Long id, boolean dryRun, String requestId, ProductRequest body) {
}
@PutMapping("/echo/products/{id}")
public Bound echo(
@PathVariable Long id,
@RequestParam boolean dryRun,
@RequestHeader("X-Request-Id") String requestId,
@RequestBody ProductRequest body) {
return new Bound(id, dryRun, requestId, body);
}
}curl -i -X PUT "http://localhost:8117/echo/products/42?dryRun=true" \
-H "Content-Type: application/json" \
-H "X-Request-Id: 7f3a9c" \
-d '{"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"}'HTTP/1.1 200
Content-Type: application/json
Content-Length: 121
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"id":42,"dryRun":true,"requestId":"7f3a9c","body":{"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"}}Chạy lại đúng lệnh đó với -v thay cho -i sẽ thấy request y như lúc nó rời curl:
> PUT /echo/products/42?dryRun=true HTTP/1.1
> Host: localhost:8117
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type: application/json
> X-Request-Id: 7f3a9c
> Content-Length: 69
>
Mỗi giá trị đi một đường riêng. 42 được cắt ra khỏi path nhờ khớp với {id}, rồi convert từ chuỗi sang Long. true được tìm trong query string theo tên dryRun và convert sang boolean. Header được tìm theo tên. Body được giao cho Jackson, vì Content-Type nói nó là JSON. curl tự thêm Host, User-Agent, Accept và Content-Length; chúng cũng tới server, nhưng không parameter nào hỏi tới.
Các thí nghiệm trong phần còn lại của bài nằm trong thêm một class dùng tạm nữa, để bản thân catalogue được sạch sẽ. Class này có sẵn mọi import mà các phần sau cần:
package com.example.demo.lab;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
import com.example.demo.product.Category;
import com.example.demo.product.ProductRequest;
import jakarta.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/lab")
public class LabController {
// the methods from the following sections go here
}Những parameter không cần annotation
Một số type được nhận ra chỉ nhờ chính type của chúng, và Spring MVC inject chúng mà không cần annotation nào:
@GetMapping("/request-info")
public String requestInfo(HttpServletRequest request, HttpMethod method, Locale locale,
UriComponentsBuilder uriBuilder) {
return "request=" + request.getRequestURI() + "?" + request.getQueryString()
+ ", method=" + method
+ ", locale=" + locale
+ ", uriBuilder=" + uriBuilder.toUriString() + "\n";
}curl "http://localhost:8117/lab/request-info?page=2" -H "Accept-Language: vi-VN"request=/lab/request-info?page=2, method=GET, locale=vi_VN, uriBuilder=http://localhost:8117| Type của parameter | Spring truyền vào gì |
|---|---|
HttpServletRequest | chính servlet request, cho những gì không annotation nào lo |
HttpMethod | HTTP method của request, ở đây là GET |
Locale | locale lấy từ Accept-Language; không có header đó thì lấy mặc định của JVM, trên máy này là en_VN |
UriComponentsBuilder | một builder đặt sẵn scheme, host và port của request hiện tại |
Là type của Spring thôi thì chưa đủ để có mặt trong danh sách đó. HttpHeaders trông như cũng phải chạy y hệt, và nó không báo lỗi — nó chỉ tới nơi trong trạng thái rỗng:
@GetMapping("/unannotated-headers")
public String unannotatedHeaders(HttpHeaders headers) {
return "size=" + headers.size() + ", names=" + headers.headerNames() + "\n";
}curl http://localhost:8117/lab/unannotated-headers -H "X-Request-Id: 7f3a9c"size=0, names=[]curl "http://localhost:8117/lab/unannotated-headers?contentLength=5" -H "X-Request-Id: 7f3a9c"size=1, names=[Content-Length]Với một parameter không annotation mà type không thuộc diện được xử lý riêng, Spring MVC coi nó là model attribute: tạo một instance mới rồi bind các parameter của request vào setter của nó, nên ?contentLength=5 biến thành lời gọi setContentLength(5). Model attribute thuộc về bài viết về form render phía server; muốn nhận header của request, parameter cần @RequestHeader, sẽ nói ở dưới.
@PathVariable: lấy giá trị từ path của URL
@PathVariable bind một đoạn {name} trong pattern của mapping vào parameter. Catalogue đã dùng nó:
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return products.get(id);
}curl -i http://localhost:8117/api/products/1HTTP/1.1 200
Content-Type: application/json
Content-Length: 61
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"id":1,"name":"Clean Code","price":32.50,"category":"BOOKS"}Annotation không ghi tên nào. Spring ghép {id} với parameter vì parameter tên là id — và điều đó chỉ đúng khi cái tên còn giữ được sau khi compile.
Spring biết tên parameter bằng cách nào: flag -parameters
File .class không lưu tên parameter của method, trừ khi compiler được truyền -parameters; flag này thêm attribute MethodParameters mà reflection đọc được. Bạn chưa từng truyền flag đó: Gradle plugin của Spring Boot tự thêm nó vào mọi task JavaCompile. Debug log của Gradle cho thấy javac đã nhận những gì; dòng dưới đây được cắt còn phần argument, path được rút gọn, classpath và danh sách file nguồn được lược đi:
./gradlew compileJava --rerun --debug | grep "Compiler arguments"Compiler arguments: -source 21 -target 21 -d <project>/build/classes/java/main -encoding UTF-8 -h <project>/build/generated/sources/headers/java/main -g -sourcepath "" -proc:none -s <project>/build/generated/sources/annotationProcessor/java/main -XDuseUnsharedTable=true -classpath <classpath> -parameters <source files>Flag này đến từ class JavaPluginAction của plugin, nơi javap tìm thấy nó dưới dạng một hằng:
javap -p -constants -cp spring-boot-gradle-plugin-4.1.1.jar org.springframework.boot.gradle.plugin.JavaPluginAction | grep -i parameters private static final java.lang.String PARAMETERS_COMPILER_ARG = "-parameters";
private void configureParametersCompilerArg(org.gradle.api.Project);
private static void lambda$configureParametersCompilerArg$0(org.gradle.api.tasks.compile.JavaCompile);Controller sau khi compile mang theo cái tên. Đây là phần thuộc về findById trong kết quả của javap -v -cp build/classes/java/main com.example.demo.product.ProductController:
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this Lcom/example/demo/product/ProductController;
0 14 1 id Ljava/lang/Long;
MethodParameters:
Name Flags
idCó hai attribute giữ tên id. LocalVariableTable là thông tin debug, có mặt vì Gradle còn truyền -g; MethodParameters chỉ tồn tại nhờ -parameters. Bỏ flag đi sẽ thấy Spring đọc cái nào. Block dưới đây xóa flag sau khi plugin của Boot đã thêm vào:
tasks.named('test') {
useJUnitPlatform()
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.remove('-parameters')
} Sau khi build lại, argument của javac kết thúc ở -classpath <classpath> mà không còn -parameters, còn javap vẫn cho thấy LocalVariableTable của findById như cũ, có cả id, nhưng không còn MethodParameters. Application khởi động mà không phàn nàn gì. Request đầu tiên tới method thì hỏng:
curl -i http://localhost:8117/api/products/1HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:05:07 GMT
Connection: close
{"timestamp":"2026-09-12T07:05:07.283Z","status":500,"error":"Internal Server Error","path":"/api/products/1"}2026-09-12T14:05:07.280+07:00 ERROR 37195 --- [demo] [nio-8117-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalArgumentException: Name for argument of type [java.lang.Long] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.] with root cause
java.lang.IllegalArgumentException: Name for argument of type [java.lang.Long] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.updateNamedValueInfo(AbstractNamedValueMethodArgumentResolver.java:184) ~[spring-web-7.0.9.jar!/:7.0.9]Thông tin debug nằm ngay đó, và Spring không dùng. Tới Spring Framework 6.0, Spring vẫn đọc dự phòng tên parameter từ LocalVariableTable, đó là lý do các tutorial cũ nói flag này không quan trọng với một bản build debug bình thường. Spring Framework 6.1 đã bỏ đường dự phòng đó, và 7.0.9 chạy đúng như trên: chỉ MethodParameters được tính.
Exception đến từ AbstractNamedValueMethodArgumentResolver, mà javap cho thấy là base class của các resolver đứng sau @PathVariable, @RequestParam, @RequestHeader và @CookieValue. Vì vậy cả bốn đều cần flag mỗi khi annotation không ghi tên. Trong cùng bản build đó, một parameter @RequestParam String q hỏng với Name for argument of type [java.lang.String] not specified, còn POST /api/products vẫn trả về 201, vì @RequestBody không tra cứu gì theo tên. Ghi tên ngay trong annotation thì chạy được dù có flag hay không; compile không có flag, method này vẫn trả lời id=1, q=coffee cho /lab/explicit/1?q=coffee:
@GetMapping("/explicit/{id}")
public String explicit(@PathVariable("id") Long id, @RequestParam("q") String q) {
return "id=" + id + ", q=" + q;
}Nhiều path variable và đặt tên tường minh
@GetMapping("/categories/{category}/products/{id}")
public String productInCategory(@PathVariable Category category,
@PathVariable("id") Long productId) {
return "category=" + category + ", productId=" + productId;
}curl http://localhost:8117/lab/categories/BOOKS/products/1category=BOOKS, productId=1Một pattern chứa bao nhiêu variable cũng được, và @PathVariable("id") là cách để parameter mang tên khác với variable của nó. Khi hai cái tên không khớp và không có gì nối chúng lại, kết quả không phải là 400:
@GetMapping("/reviews/{reviewId}")
public String review(@PathVariable Long id) {
return "id=" + id;
}curl -i http://localhost:8117/lab/reviews/7HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.326Z","status":500,"error":"Internal Server Error","path":"/lab/reviews/7"}2026-09-12T14:01:33.326+07:00 WARN 34784 --- [demo] [nio-8117-exec-9] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingPathVariableException: Required URI template variable 'id' for method parameter type Long is not present]Client đã gửi một URL hoàn toàn hợp lệ; chính mapping và method không thống nhất với nhau về một cái tên. Đó là bug trong controller, nên Spring trả về 500 thay vì đổ lỗi cho request, dù nó chỉ ghi log vấn đề ở mức WARN.
Path variable tùy chọn: required = false và Optional
@PathVariable mặc định là bắt buộc, và required = false trông như cách để một đoạn path trở thành tùy chọn. Trên method chỉ map một pattern thì nó chẳng thay đổi gì: method chỉ chạy khi pattern đã khớp, nên variable lúc nào cũng có. Nó chỉ có tác dụng khi một method map hai pattern, một có variable và một không:
@GetMapping({"/stock", "/stock/{warehouse}"})
public String stock(@PathVariable(required = false) String warehouse) {
return "warehouse=" + warehouse;
}
@GetMapping({"/shipping", "/shipping/{zone}"})
public String shipping(@PathVariable Optional<String> zone) {
return "zone=" + zone;
}
@GetMapping({"/returns", "/returns/{reason}"})
public String returns(@PathVariable String reason) {
return "reason=" + reason;
}| Request | Kết quả |
|---|---|
GET /lab/stock | warehouse=null |
GET /lab/stock/hanoi | warehouse=hanoi |
GET /lab/shipping | zone=Optional.empty |
GET /lab/shipping/north | zone=Optional[north] |
GET /lab/returns | 500, MissingPathVariableException: Required URI template variable 'reason' for method parameter type String is not present |
GET /lab/returns/damaged | reason=damaged |
Path variable tùy chọn khiến một method phục vụ hai URL thường mang hai ý nghĩa khác nhau. Hai method, mỗi method một pattern, dễ đọc hơn, và tránh được cái bẫy ở cuối bảng: variable bắt buộc nằm dưới một pattern không khai báo nó là lỗi 500, không phải 404.
Convert path variable sang Long, UUID và enum
Path variable nào cũng tới dưới dạng chuỗi. Spring convert nó sang type của parameter, và khi convert thất bại thì client nhận 400:
curl -i http://localhost:8117/api/products/abcHTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.226Z","status":400,"error":"Bad Request","path":"/api/products/abc"}2026-09-12T14:01:33.223+07:00 WARN 34784 --- [demo] [io-8117-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; For input string: "abc"]Body là error response mặc định của Spring Boot: status, reason phrase và path, không có gì về parameter nào hỏng hay vì sao. Lời giải thích chỉ nằm trong log của server. Tùy biến error body đó sẽ được nói ở phần sau của chương này.
Lỗi 400 đó phụ thuộc vào pattern /{id} trơn dùng ở đây. Một pattern chỉ nhận chữ số, như pattern ở bài trước, không bao giờ để abc tới được method, nên việc convert không hề diễn ra.
UUID và enum được convert theo cùng cách:
@GetMapping("/orders/{orderId}")
public String order(@PathVariable UUID orderId) {
return "orderId=" + orderId + ", version=" + orderId.version();
}| Request | Status | Kết quả, hoặc lý do trong log |
|---|---|---|
GET /lab/orders/3f2b8c1e-9d4a-4e7b-a1c2-5d6e7f8a9b0c | 200 | orderId=3f2b8c1e-9d4a-4e7b-a1c2-5d6e7f8a9b0c, version=4 |
GET /lab/orders/12345 | 400 | Invalid UUID string: 12345 |
GET /lab/categories/BOOKS/products/1 | 200 | category=BOOKS, productId=1 |
GET /lab/categories/books/products/1 | 400 | Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.PathVariable com.example.demo.product.Category] for value [books] |
Enum được convert theo đúng tên của constant, nên books không phải là BOOKS.
@RequestParam: query string và field của form
@RequestParam đọc một parameter có tên từ query string. Một method cho thấy cả bốn cách khai báo:
@GetMapping("/search")
public String search(@RequestParam String q,
@RequestParam(defaultValue = "name") String sort,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam Optional<BigDecimal> maxPrice) {
return "q=" + q + ", sort=" + sort + ", minPrice=" + minPrice + ", maxPrice=" + maxPrice;
}curl "http://localhost:8117/lab/search?q=coffee"q=coffee, sort=name, minPrice=null, maxPrice=Optional.emptycurl "http://localhost:8117/lab/search?q=coffee&sort=price&minPrice=10&maxPrice=20"q=coffee, sort=price, minPrice=10, maxPrice=Optional[20]Mặc định là bắt buộc: lỗi 400 khi thiếu parameter
q không có attribute nào, nghĩa là bắt buộc:
curl -i http://localhost:8117/lab/searchHTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.335Z","status":400,"error":"Bad Request","path":"/lab/search"}2026-09-12T14:01:33.335+07:00 WARN 34784 --- [demo] [io-8117-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter 'q' for method parameter type String is not present]Ba parameter còn lại là các lối thoát, và mỗi cái trao cho method một thứ khác nhau khi parameter vắng mặt:
| Khai báo | Khi parameter vắng mặt |
|---|---|
@RequestParam String q | 400, MissingServletRequestParameterException |
@RequestParam(defaultValue = "name") String sort | "name" |
@RequestParam(required = false) BigDecimal minPrice | null |
@RequestParam Optional<BigDecimal> maxPrice | Optional.empty |
Bản thân defaultValue đã làm parameter thành không bắt buộc; không cần thêm required = false bên cạnh. Có điều, vắng mặt không giống với rỗng. ?q= thỏa q bắt buộc bằng một chuỗi rỗng và trả về q=, sort=name, minPrice=null, maxPrice=Optional.empty, còn ?q=coffee&minPrice= cho minPrice=null, vì giá trị rỗng được convert thành null với BigDecimal. Giá trị không convert được là 400, y như với path variable: ?q=coffee&minPrice=cheap ghi log Method parameter 'minPrice': Failed to convert value of type 'java.lang.String' to required type 'java.math.BigDecimal'; Character c is neither a decimal digit number, decimal point, nor "e" notation exponential mark.
Parameter primitive bị thiếu gây lỗi 500
required = false trên một type primitive vẫn compile, vẫn khởi động, rồi hỏng ở request đầu tiên không gửi parameter đó:
@GetMapping("/page")
public String page(@RequestParam(required = false) int limit) {
return "limit=" + limit;
}curl -i http://localhost:8117/lab/pageHTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
Connection: close
{"timestamp":"2026-09-12T07:01:33.392Z","status":500,"error":"Internal Server Error","path":"/lab/page"}2026-09-12T14:01:33.390+07:00 ERROR 34784 --- [demo] [io-8117-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: Optional int parameter 'limit' is present but cannot be translated into a null value due to being declared as a primitive type. Consider declaring it as object wrapper for the corresponding primitive type.] with root cause
java.lang.IllegalStateException: Optional int parameter 'limit' is present but cannot be translated into a null value due to being declared as a primitive type. Consider declaring it as object wrapper for the corresponding primitive type.
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.handleNullValue(AbstractNamedValueMethodArgumentResolver.java:266) ~[spring-web-7.0.9.jar!/:7.0.9]Message nói vậy, nhưng chẳng có gì tên limit được gửi lên. Parameter tùy chọn bị thiếu sẽ thành null, và null không nhét vào int được. ?limit=5 chạy tốt và ?limit=abc là lỗi 400 bình thường, nên bug chỉ lộ ra ở đúng request mà không ai thử. Hãy khai báo Integer limit, hoặc cho parameter một giá trị mặc định, cách này còn lo luôn giá trị rỗng:
@GetMapping("/page-default")
public String pageDefault(@RequestParam(defaultValue = "20") int limit) {
return "limit=" + limit;
}Cả /lab/page-default lẫn /lab/page-default?limit= đều trả lời limit=20.
Nhiều giá trị: List, Map và MultiValueMap
Parameter lặp lại được bind vào một List:
@GetMapping("/tags")
public String tags(@RequestParam List<String> tag) {
return "tag=" + tag + ", size=" + tag.size();
}| Query string | Kết quả |
|---|---|
?tag=java&tag=spring | tag=[java, spring], size=2 |
?tag=java,spring | tag=[java, spring], size=2 |
?tag=java,spring&tag=boot | tag=[java,spring, boot], size=2 |
| không có | 400, Required request parameter 'tag' for method parameter type List is not present |
Dòng thứ ba là cái bẫy. Giá trị ngăn cách bằng dấu phẩy chỉ được tách khi parameter xuất hiện một lần; khi tên lặp lại, từng giá trị được giữ nguyên, kể cả dấu phẩy. Hãy chọn một cách viết cho API và ghi rõ trong tài liệu.
Để lấy mọi parameter mà không phải gọi tên từng cái, bind vào một map:
@GetMapping("/params")
public String params(@RequestParam Map<String, String> single,
@RequestParam MultiValueMap<String, String> multi) {
return "Map=" + single + "\nMultiValueMap=" + multi + "\n";
}curl "http://localhost:8117/lab/params?q=coffee&tag=java&tag=spring"Map={q=coffee, tag=java}
MultiValueMap={q=[coffee], tag=[java, spring]}Map<String, String> chỉ giữ giá trị đầu tiên của một tên lặp lại; MultiValueMap giữ tất cả.
Parameter enum và phân biệt hoa thường
@GetMapping("/by-category")
public String byCategory(@RequestParam Category category) {
return "category=" + category;
}| Query string | Status | Kết quả, hoặc lý do trong log |
|---|---|---|
?category=BOOKS | 200 | category=BOOKS |
?category=books | 400 | Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam com.example.demo.product.Category] for value [books] |
?category= | 400 | Required request parameter 'category' for method parameter type Category is present but converted to null |
Quy tắc giống hệt ở path: đúng tên constant, đúng hoa thường. Dòng cuối cho thấy giá trị rỗng của một enum bắt buộc bị báo lỗi ra sao: nó convert thành null, và một giá trị bắt buộc mà rốt cuộc là null thì bị tính là thiếu.
Đọc field của form bằng @RequestParam
Một form HTML, hoặc curl -d không kèm Content-Type, gửi các field dưới dạng body application/x-www-form-urlencoded. @RequestParam đọc được cả chúng:
@PostMapping("/form")
public String form(@RequestParam String name, @RequestParam BigDecimal price) {
return "name=" + name + ", price=" + price;
}
@PutMapping("/form")
public String formPut(@RequestParam String name, @RequestParam BigDecimal price) {
return "PUT name=" + name + ", price=" + price;
}curl -X POST http://localhost:8117/lab/form -d "name=Desk+Lamp&price=24.00"name=Desk Lamp, price=24.00Với @RequestParam, query string và body dạng form là một tập parameter chung. POST /lab/form?name=Desk+Lamp kèm body price=24.00 cho kết quả y hệt, và khi một tên xuất hiện ở cả hai nơi thì các giá trị bị nối lại: ?name=FromQuery cộng name=FromBody trong body tới nơi thành name=FromQuery,FromBody.
Với POST, servlet container tự đọc body dạng form. Với các method khác, FormContentFilter của Spring, được Spring Boot đăng ký sẵn, làm việc đó: một PUT với cùng body trả lời PUT name=Desk Lamp, price=24.00, và sau khi khởi động lại với --spring.mvc.formcontent.filter.enabled=false, chính PUT đó thành lỗi 400 vì thiếu name, trong khi POST vẫn chạy.
@RequestHeader và @CookieValue
@RequestHeader nhận tên header và hỗ trợ đúng các attribute required và defaultValue:
@GetMapping("/headers")
public String headers(@RequestHeader("X-Request-Id") String requestId,
@RequestHeader(name = "Accept-Language", defaultValue = "en") String language) {
return "requestId=" + requestId + ", language=" + language;
}curl http://localhost:8117/lab/headers -H "X-Request-Id: 7f3a9c"requestId=7f3a9c, language=encurl http://localhost:8117/lab/headers -H "x-request-id: 7f3a9c" -H "Accept-Language: vi-VN"requestId=7f3a9c, language=vi-VNRequest thứ hai viết tên header bằng chữ thường mà vẫn khớp: tên header trong HTTP không phân biệt hoa thường, và việc tra cứu cũng vậy. Bỏ hẳn header bắt buộc thì nhận lỗi 400 với log MissingRequestHeaderException: Required request header 'X-Request-Id' for method parameter type String is not present. Việc convert cũng áp dụng cho header:
@GetMapping("/header-number")
public String headerNumber(@RequestHeader("X-Page") int page) {
return "page=" + page;
}X-Page: 3 cho page=3; X-Page: abc là lỗi 400 với log MethodArgumentTypeMismatchException: Method parameter 'X-Page': Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "abc".
Đọc mọi header bằng HttpHeaders
Không ghi tên, @RequestHeader trên một parameter HttpHeaders nhận toàn bộ header:
@GetMapping("/all-headers")
public String allHeaders(@RequestHeader HttpHeaders headers) {
return "size=" + headers.size()
+ ", names=" + headers.headerNames()
+ ", user-agent=" + headers.getFirst("user-agent")
+ ", accept=" + headers.getAccept() + "\n";
}curl http://localhost:8117/lab/all-headers -H "X-Request-Id: 7f3a9c"size=4, names=[Host, User-Agent, Accept, X-Request-Id], user-agent=curl/8.7.1, accept=[*/*]getFirst("user-agent") tìm thấy User-Agent, còn các getter có sẵn type như getAccept() parse giá trị thành object. Trong Spring Framework 7, HttpHeaders không còn implement MultiValueMap, nên bạn duyệt nó bằng headerNames() và headerSet(). Parameter HttpHeaders không annotation ở phần trước thì không nhận được header nào trong số này.
Cookie với @CookieValue
@GetMapping("/theme")
public String theme(@CookieValue("theme") String theme) {
return "theme=" + theme;
}curl http://localhost:8117/lab/theme -b "theme=dark"theme=dark@CookieValue cũng có các attribute required và defaultValue. Không có cookie thì request là lỗi 400 với log MissingRequestCookieException: Required cookie 'theme' for method parameter type String is not present, còn @CookieValue("visits") int visits nhận visits=abc là lỗi 400 vì convert thất bại.
@RequestBody: JSON thành Java record
@RequestBody giao toàn bộ body cho một message converter, và converter biến nó thành type của parameter. Method create của catalogue đã dùng nó:
curl -i -X POST http://localhost:8117/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'HTTP/1.1 201
Content-Type: application/json
Content-Length: 66
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"id":4,"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}Jackson, được web starter kéo theo, đã biến JSON thành một ProductRequest. Cơ chế mapping đó và cách điều khiển nó là chủ đề của bài tiếp theo. Câu hỏi ở đây là chuyện gì xảy ra khi body không như method mong đợi.
Thiếu Content-Type, body rỗng và JSON sai cú pháp
Quên -H "Content-Type: application/json" là lỗi hay gặp nhất, vì khi đó curl -d gắn nhãn body là form:
curl -i -X POST http://localhost:8117/api/products \
-d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'HTTP/1.1 415
Accept: application/json, application/*+json
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"timestamp":"2026-09-12T07:01:33.639Z","status":415,"error":"Unsupported Media Type","path":"/api/products"}2026-09-12T14:01:33.638+07:00 WARN 34784 --- [demo] [nio-8117-exec-6] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported]415 Unsupported Media Type, và header Accept của response liệt kê những gì endpoint sẵn sàng đọc. Bản thân JSON không sai; chỉ là không converter nào đọc một form thành ProductRequest. Bỏ hẳn header bằng -H "Content-Type:" cũng cho đúng lỗi 415 đó, với log Content-Type 'application/octet-stream' is not supported: body không có type bị coi là byte thô.
Các lỗi còn lại đều là 400, mỗi lỗi một lý do riêng trong log:
Gửi kèm Content-Type: application/json | Status | Lý do trong log |
|---|---|---|
không có body, hoặc -d '' | 400 | HttpMessageNotReadableException: Required request body is missing: public com.example.demo.product.Product com.example.demo.product.ProductController.create(com.example.demo.product.ProductRequest) |
{"name":"Desk Lamp","price":24.00, | 400 | HttpMessageNotReadableException: JSON parse error: Unexpected end-of-input within/between Object entries |
{"name":"Desk Lamp","price":"cheap","category":"ELECTRONICS"} | 400 | HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String "cheap": not a valid representation |
required = false trên body biến "không có body" thành null thay vì lỗi 400:
@PostMapping("/optional-body")
public String optionalBody(@RequestBody(required = false) ProductRequest request) {
return "request=" + request;
}Với Content-Type: application/json và không có body, nó trả lời request=null; với JSON Desk Lamp ở trên thì là request=ProductRequest[name=Desk Lamp, price=24.00, category=ELECTRONICS]. Kiểm tra các field bên trong body có hợp lệ không — tên để trống, giá âm — là một bước riêng với Bean Validation, hai bài nữa sẽ tới.
ResponseEntity: status, header và body trong một giá trị trả về
Method trả về Product để Spring chọn status — 200, hoặc bất cứ gì @ResponseStatus cố định — và ghi object thành body. ResponseEntity<T> gói body cùng với status và header, nên method tự quyết cả ba ngay lúc chạy. Các builder static của nó phủ những trường hợp thường gặp:
| Builder | Status | Dùng cho |
|---|---|---|
ResponseEntity.ok(body) | 200 | giống như trả về body; dùng ok().header(…).body(body) khi cần thêm header |
ResponseEntity.created(uri).body(body) | 201 | resource mới; uri trở thành header Location |
ResponseEntity.noContent().build() | 204 | thành công mà không có gì để gửi lại |
ResponseEntity.notFound().build() | 404 | resource không tồn tại |
ResponseEntity.status(HttpStatus.ACCEPTED).body(body) | 202, hoặc status bất kỳ | status không có shortcut riêng |
ResponseEntity.of(optional) | 200 hoặc 404 | tra cứu có thể không tìm thấy gì |
status() kèm một header tự đặt, trong cùng một chuỗi gọi:
@PostMapping("/imports")
public ResponseEntity<Map<String, String>> startImport() {
return ResponseEntity.status(HttpStatus.ACCEPTED)
.header("X-Import-Id", "imp-42")
.body(Map.of("status", "QUEUED"));
}curl -i -X POST http://localhost:8117/lab/importsHTTP/1.1 202
X-Import-Id: imp-42
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:01:33 GMT
{"status":"QUEUED"}header(name, values…) gọi bao nhiêu lần tùy ý. Content-Type, Transfer-Encoding và Date không do method đặt; JSON converter và server đã thêm chúng.
ResponseEntity.of(Optional) bớt được một câu if khi tra cứu:
@GetMapping("/featured")
public ResponseEntity<String> featured(@RequestParam(defaultValue = "false") boolean empty) {
Optional<String> featured = empty ? Optional.empty() : Optional.of("Clean Code");
return ResponseEntity.of(featured);
}curl -i "http://localhost:8117/lab/featured?empty=true"HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:01:33 GMTKhông có ?empty=true, cùng method đó trả lời 200 với body Clean Code. Lỗi 404 có body rỗng chứ không phải error response JSON của Spring Boot, vì chẳng có gì hỏng: method chỉ đơn giản trả về 404.
ResponseEntity extend HttpEntity, class chỉ giữ header và body. RequestEntity, bản tương ứng dành cho request, cũng extend nó và chủ yếu được dùng với HTTP client.
@ResponseStatus hay ResponseEntity?
@ResponseStatus cố định status từ lúc viết code; ResponseEntity quyết định nó khi request chạy. Method delete ở đầu bài cho thấy vì sao điều đó quan trọng: @ResponseStatus(HttpStatus.NO_CONTENT) trả 204 cho một sản phẩm chưa từng tồn tại, vì annotation không thể biết method đã tìm thấy gì.
Đặt cả hai lên một method không làm chúng cộng lại với nhau:
@GetMapping("/which-status")
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<String> whichStatus() {
return ResponseEntity.ok("ResponseEntity said 200");
}curl -i http://localhost:8117/lab/which-statusHTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 23
Date: Sat, 12 Sep 2026 07:01:33 GMT
ResponseEntity said 200ResponseEntity thắng, không một lời cảnh báo. Mỗi method hãy chọn một:
- Trả về object, chỉ thêm
@ResponseStatuskhi status không phải 200, nếu mọi lần gọi thành công đều kết thúc giống nhau: một danh sách, hoặc một thao tác tạo không cầnLocation. - Trả về
ResponseEntitykhi status hoặc header tùy vào chuyện đã xảy ra: tìm thấy hay không, đã xóa hay vốn không có, tạo mới kèmLocationchứa id vừa sinh.
Nâng cấp catalogue sản phẩm
Giờ catalogue nhận được những gì phần đầu bài cho thấy nó còn thiếu: 404 thật sự, 204 chỉ khi có thứ bị xóa, 201 kèm header Location, và bộ lọc cho danh sách. Các thay đổi so với controller ở đầu bài:
package com.example.demo.product;
import java.math.BigDecimal;
import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
public ProductController() {
add(new ProductRequest("Clean Code", new BigDecimal("32.50"), Category.BOOKS));
add(new ProductRequest("Mechanical Keyboard", new BigDecimal("89.90"), Category.ELECTRONICS));
add(new ProductRequest("Arabica Coffee Beans", new BigDecimal("14.20"), Category.GROCERY));
}
@GetMapping
public List<Product> findAll() {
public List<Product> findAll(@RequestParam(required = false) Category category,
@RequestParam(required = false) BigDecimal minPrice) {
return products.values().stream()
.filter(p -> category == null || p.category() == category)
.filter(p -> minPrice == null || p.price().compareTo(minPrice) >= 0)
.sorted(Comparator.comparing(Product::id))
.toList();
}
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return products.get(id);
public ResponseEntity<Product> findById(@PathVariable Long id) {
return ResponseEntity.of(Optional.ofNullable(products.get(id)));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@RequestBody ProductRequest request) {
return add(request);
public ResponseEntity<Product> create(@RequestBody ProductRequest request,
@RequestHeader("X-Request-Id") Optional<String> requestId) {
Product product = add(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.id())
.toUri();
return ResponseEntity.created(location)
.header("X-Request-Id", requestId.orElseGet(() -> UUID.randomUUID().toString()))
.body(product);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (products.remove(id) == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
private Product add(ProductRequest request) {
long id = sequence.incrementAndGet();
Product product = new Product(id, request.name(), request.price(), request.category());
products.put(id, product);
return product;
}
}Mỗi thay đổi làm gì:
findAllkhai báo cả hai bộ lọc làrequired = false, nênGET /api/productskhông kèm parameter vẫn liệt kê tất cả, và mỗi bộ lọc chỉ áp dụng khi giá trị của nó khácnull.categorylà enum, nên?category=bookslà lỗi 400, như ở phần về enum.findByIddùngResponseEntity.of, biến mộtOptionalrỗng thành 404.createnhận một headerX-Request-Idtùy chọn và trả nó lại trong response, tự sinh UUID khi client không gửi, để client ghép được response với request mà nó đã gửi.deletedùng giá trị trả về củaMap.remove, lànullkhi không có gì để xóa.
POST trả về 201 Created kèm header Location
curl -i -X POST http://localhost:8117/api/products \
-H "Content-Type: application/json" \
-H "X-Request-Id: 7f3a9c" \
-d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'HTTP/1.1 201
Location: http://localhost:8117/api/products/4
X-Request-Id: 7f3a9c
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:05:08 GMT
{"id":4,"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}
ServletUriComponentsBuilder.fromCurrentRequest() bắt đầu từ URL mà client đã gọi, http://localhost:8117/api/products; path("/{id}") nối thêm một đoạn template, buildAndExpand(product.id()) điền 4 vào đó, và toUri() tạo ra URI mà created() ghi vào Location. Sản phẩm thứ hai, {"name":"Green Tea","price":6.80,"category":"GROCERY"} gửi không kèm X-Request-Id, nhận một id tự sinh. Mấy dòng đầu của response đó:
HTTP/1.1 201
Location: http://localhost:8117/api/products/5
X-Request-Id: 2e37d6d3-49bb-4883-bdb7-97129cdf42cafromCurrentRequest() copy luôn cả query string. Sản phẩm thứ ba, {"name":"Refactoring","price":41.00,"category":"BOOKS"}, được POST tới /api/products?source=import và nhận về Location: http://localhost:8117/api/products/6?source=import — một query parameter chẳng liên quan gì tới sản phẩm 6. Khi endpoint tạo mới có nhận query parameter, hãy dùng fromCurrentRequestUri(), method giữ scheme, host, port và path nhưng bỏ query. Đặt hai cách cạnh nhau, gọi cho POST /lab/uris?source=import:
@PostMapping("/uris")
public String uris() {
return "fromCurrentRequest() -> "
+ ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(6).toUri()
+ "\nfromCurrentRequestUri() -> "
+ ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{id}").buildAndExpand(6).toUri()
+ "\n";
}fromCurrentRequest() -> http://localhost:8117/lab/uris/6?source=import
fromCurrentRequestUri() -> http://localhost:8117/lab/uris/6GET trả về 404 khi sản phẩm không tồn tại
curl -i http://localhost:8117/api/products/99HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:05:08 GMTGET /api/products/4 vẫn trả 200 cùng chiếc Desk Lamp.
DELETE trả về 204, hoặc 404 khi không có gì để xóa
curl -i -X DELETE http://localhost:8117/api/products/4HTTP/1.1 204
Date: Sat, 12 Sep 2026 07:05:08 GMTChạy lại đúng lệnh đó lần thứ hai:
HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:05:08 GMTGET lọc danh sách theo category và giá tối thiểu
Sau ba lần tạo và một lần xóa ở trên, catalogue còn các sản phẩm 1, 2, 3, 5 và 6.
curl -i "http://localhost:8117/api/products?category=BOOKS"HTTP/1.1 200
Content-Type: application/json
Content-Length: 126
Date: Sat, 12 Sep 2026 07:05:08 GMT
[{"id":1,"name":"Clean Code","price":32.50,"category":"BOOKS"},{"id":6,"name":"Refactoring","price":41.00,"category":"BOOKS"}]curl "http://localhost:8117/api/products?minPrice=20"[{"id":1,"name":"Clean Code","price":32.50,"category":"BOOKS"},{"id":2,"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"},{"id":6,"name":"Refactoring","price":41.00,"category":"BOOKS"}]curl "http://localhost:8117/api/products?category=ELECTRONICS&minPrice=50"[{"id":2,"name":"Mechanical Keyboard","price":89.90,"category":"ELECTRONICS"}]?minPrice=cheap là lỗi 400, cùng MethodArgumentTypeMismatchException như ở phần @RequestParam.
So sánh các annotation bind request
Mọi ô dưới đây đều lấy từ các lần chạy trong bài:
| Annotation | Giá trị lấy từ | Mặc định bắt buộc | Khi thiếu giá trị | Khi không convert được |
|---|---|---|---|---|
@PathVariable | một đoạn {name} trong pattern của mapping | có | 500, MissingPathVariableException: mapping không khai báo variable đó | 400, MethodArgumentTypeMismatchException |
@RequestParam | query string, và các field của body dạng form | có | 400, MissingServletRequestParameterException | 400, MethodArgumentTypeMismatchException |
@RequestHeader | một header của request, tên so khớp không phân biệt hoa thường | có | 400, MissingRequestHeaderException | 400, MethodArgumentTypeMismatchException |
@CookieValue | một cookie | có | 400, MissingRequestCookieException | 400, MethodArgumentTypeMismatchException |
@RequestBody | toàn bộ body, đọc bởi message converter chấp nhận Content-Type của nó | có | 400, HttpMessageNotReadableException | 400, HttpMessageNotReadableException; 415 khi không converter nào chấp nhận Content-Type |
Còn hai lỗi 500 nữa là lỗi khai báo chứ không phải lỗi của request, nên bảng không có cột cho chúng: một primitive khai báo required = false rồi bị bỏ trống, và bất kỳ annotation tra theo tên nào không ghi tên tường minh trong class compile không có -parameters.
FAQ
Vì sao Spring báo "parameter name information not available via reflection"?
Class được compile không có -parameters, nên @PathVariable, @RequestParam, @RequestHeader hay @CookieValue không ghi tên sẽ không có gì để dựa vào. Từ Spring Framework 6.1, thông tin debug trong LocalVariableTable không còn được dùng làm đường dự phòng. Gradle plugin của Spring Boot thêm flag này vào mọi task JavaCompile, nên hãy khôi phục nó ở bản build nào làm mất, hoặc ghi tên cho mọi giá trị, như @PathVariable("id").
@PathVariable và @RequestParam khác nhau thế nào?
@PathVariable đọc một đoạn path mà mapping khai báo là {name}, như trong /api/products/42. @RequestParam đọc một giá trị có tên từ query string hoặc body dạng form, như trong /api/products?category=BOOKS. Cả hai đều mặc định bắt buộc và đều trả 400 cho giá trị không convert được, nhưng path variable bị thiếu lại là 500, vì điều đó nghĩa là bản thân mapping đã sai.
Vì sao endpoint @RequestBody trả về 415 Unsupported Media Type?
Content-Type của request không phải loại mà một message converter đọc được thành type của bạn. curl -d gửi application/x-www-form-urlencoded nếu không được chỉ định khác, và khi không có header nào thì Spring coi là application/octet-stream. Hãy gửi Content-Type: application/json; header Accept trong response 415 liệt kê các type mà endpoint chấp nhận.
Làm sao để @RequestParam không bắt buộc?
Cho nó một defaultValue, khai báo required = false và xử lý null, hoặc dùng Optional<T>. Đừng kết hợp required = false với primitive như int: giá trị bị thiếu thành null, thứ mà int không chứa được, và request hỏng với lỗi 500 IllegalStateException. Hãy dùng Integer, hoặc một defaultValue.
Controller Spring Boot nên trả về ResponseEntity hay object?
Trả về object khi mọi lần gọi thành công đều có cùng một status, thêm @ResponseStatus nếu status đó không phải 200. Trả về ResponseEntity khi status hoặc header tùy vào kết quả, như 404 cho resource không tồn tại hay 201 kèm Location. Đừng đặt cả hai lên cùng một method: status trong ResponseEntity sẽ lặng lẽ thắng.
Làm sao đọc tất cả query parameter hoặc tất cả header cùng lúc?
@RequestParam Map<String, String> cho mọi query parameter với giá trị đầu tiên của nó, còn @RequestParam MultiValueMap<String, String> giữ cả các giá trị lặp lại. Với header, dùng @RequestHeader HttpHeaders. Không có annotation, parameter HttpHeaders tới nơi trong trạng thái rỗng.
Kết luận
Parameter của một handler method khai báo mỗi giá trị nằm ở đâu. @PathVariable lấy một đoạn mà pattern đặt tên, @RequestParam lấy một parameter của query hoặc form, @RequestHeader và @CookieValue lấy một header hay một cookie, còn @RequestBody lấy toàn bộ body qua message converter. Tất cả đều mặc định bắt buộc và convert chuỗi sang type đã khai báo, nên giá trị thiếu hoặc sai định dạng cho client một lỗi 400 — trừ khi lỗi nằm trong code. Path variable mà mapping không khai báo, primitive bị thiếu dù đã khai báo required = false, và annotation tra theo tên trong class compile không có -parameters đều là lỗi 500. Ở chiều ra, ResponseEntity cho method chọn status, header và body lúc runtime, và chính nó đã biến những 200 và 204 cố định của catalogue thành 201 kèm Location, 404 và 204 đúng với ý nghĩa của chúng.
Bài tiếp theo nói về chính body: JSON với Jackson và DTO — serialize và deserialize hoạt động ra sao, Jackson 3 trong Spring Boot 4 thay đổi những gì, vì sao nên tách type của request và response khỏi entity, và MapStruct map giữa chúng thế nào.