Command Palette

Search for a command to run...

[Spring Boot Basics] Nhận và trả dữ liệu trong Spring Boot: @PathVariable, @RequestParam, @RequestBody, @RequestHeader và ResponseEntity

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.

Path, query string, header và body của một request cắm vào cùng một method trong controller, và một response 201 đi ra

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:

src/main/java/com/example/demo/product/Category.java
package com.example.demo.product;
 
public enum Category {
    BOOKS, ELECTRONICS, GROCERY
}
src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record Product(Long id, String name, BigDecimal price, Category category) {
}
src/main/java/com/example/demo/product/ProductRequest.java
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 replaceupdate được bỏ ra vì bài này không đụng tới chúng:

src/main/java/com/example/demo/product/ProductController.java
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:

Bash
curl -i http://localhost:8117/api/products/99
Text
HTTP/1.1 200
Content-Length: 0
Date: Sat, 12 Sep 2026 07:01:33 GMT

findById 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 đó:

Bash
curl -i -X DELETE http://localhost:8117/api/products/99
Text
HTTP/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 requestVí dụAnnotation
Path/api/products/42@PathVariable
Query string, và các field của body dạng form?category=BOOKS@RequestParam
HeaderX-Request-Id: 7f3a9c@RequestHeader
CookieCookie: 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:

src/main/java/com/example/demo/echo/EchoController.java
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);
    }
}
Bash
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"}'
Text
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:

Text
> 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
>

Request PUT lấy từ curl -v, với path, query string, header X-Request-Id và body JSON được nối tới đúng parameter @PathVariable, @RequestParam, @RequestHeader và @RequestBody đã nhận chúng

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, AcceptContent-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:

src/main/java/com/example/demo/lab/LabController.java
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:

Java
@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";
}
Bash
curl "http://localhost:8117/lab/request-info?page=2" -H "Accept-Language: vi-VN"
Text
request=/lab/request-info?page=2, method=GET, locale=vi_VN, uriBuilder=http://localhost:8117
Type của parameterSpring truyền vào gì
HttpServletRequestchính servlet request, cho những gì không annotation nào lo
HttpMethodHTTP method của request, ở đây là GET
Localelocale 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
UriComponentsBuildermộ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:

Java
@GetMapping("/unannotated-headers")
public String unannotatedHeaders(HttpHeaders headers) {
    return "size=" + headers.size() + ", names=" + headers.headerNames() + "\n";
}
Bash
curl http://localhost:8117/lab/unannotated-headers -H "X-Request-Id: 7f3a9c"
Text
size=0, names=[]
Bash
curl "http://localhost:8117/lab/unannotated-headers?contentLength=5" -H "X-Request-Id: 7f3a9c"
Text
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ó:

Java
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
    return products.get(id);
}
Bash
curl -i http://localhost:8117/api/products/1
Text
HTTP/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:

Bash
./gradlew compileJava --rerun --debug | grep "Compiler arguments"
Text
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:

Bash
javap -p -constants -cp spring-boot-gradle-plugin-4.1.1.jar org.springframework.boot.gradle.plugin.JavaPluginAction | grep -i parameters
Text
  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:

Text
      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
      id

Có 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:

build.gradle
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:

Bash
curl -i http://localhost:8117/api/products/1
Text
HTTP/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"}
Text
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@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:

Java
@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

Java
@GetMapping("/categories/{category}/products/{id}")
public String productInCategory(@PathVariable Category category,
                                @PathVariable("id") Long productId) {
    return "category=" + category + ", productId=" + productId;
}
Bash
curl http://localhost:8117/lab/categories/BOOKS/products/1
Text
category=BOOKS, productId=1

Mộ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:

Java
@GetMapping("/reviews/{reviewId}")
public String review(@PathVariable Long id) {
    return "id=" + id;
}
Bash
curl -i http://localhost:8117/lab/reviews/7
Text
HTTP/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"}
Text
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:

Java
@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;
}
RequestKết quả
GET /lab/stockwarehouse=null
GET /lab/stock/hanoiwarehouse=hanoi
GET /lab/shippingzone=Optional.empty
GET /lab/shipping/northzone=Optional[north]
GET /lab/returns500, MissingPathVariableException: Required URI template variable 'reason' for method parameter type String is not present
GET /lab/returns/damagedreason=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:

Bash
curl -i http://localhost:8117/api/products/abc
Text
HTTP/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"}
Text
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:

Java
@GetMapping("/orders/{orderId}")
public String order(@PathVariable UUID orderId) {
    return "orderId=" + orderId + ", version=" + orderId.version();
}
RequestStatusKết quả, hoặc lý do trong log
GET /lab/orders/3f2b8c1e-9d4a-4e7b-a1c2-5d6e7f8a9b0c200orderId=3f2b8c1e-9d4a-4e7b-a1c2-5d6e7f8a9b0c, version=4
GET /lab/orders/12345400Invalid UUID string: 12345
GET /lab/categories/BOOKS/products/1200category=BOOKS, productId=1
GET /lab/categories/books/products/1400Failed 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:

Java
@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;
}
Bash
curl "http://localhost:8117/lab/search?q=coffee"
Text
q=coffee, sort=name, minPrice=null, maxPrice=Optional.empty
Bash
curl "http://localhost:8117/lab/search?q=coffee&sort=price&minPrice=10&maxPrice=20"
Text
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:

Bash
curl -i http://localhost:8117/lab/search
Text
HTTP/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"}
Text
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áoKhi parameter vắng mặt
@RequestParam String q400, MissingServletRequestParameterException
@RequestParam(defaultValue = "name") String sort"name"
@RequestParam(required = false) BigDecimal minPricenull
@RequestParam Optional<BigDecimal> maxPriceOptional.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 đó:

Java
@GetMapping("/page")
public String page(@RequestParam(required = false) int limit) {
    return "limit=" + limit;
}
Bash
curl -i http://localhost:8117/lab/page
Text
HTTP/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"}
Text
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:

Java
@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:

Java
@GetMapping("/tags")
public String tags(@RequestParam List<String> tag) {
    return "tag=" + tag + ", size=" + tag.size();
}
Query stringKết quả
?tag=java&tag=springtag=[java, spring], size=2
?tag=java,springtag=[java, spring], size=2
?tag=java,spring&tag=boottag=[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:

Java
@GetMapping("/params")
public String params(@RequestParam Map<String, String> single,
                     @RequestParam MultiValueMap<String, String> multi) {
    return "Map=" + single + "\nMultiValueMap=" + multi + "\n";
}
Bash
curl "http://localhost:8117/lab/params?q=coffee&tag=java&tag=spring"
Text
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

Java
@GetMapping("/by-category")
public String byCategory(@RequestParam Category category) {
    return "category=" + category;
}
Query stringStatusKết quả, hoặc lý do trong log
?category=BOOKS200category=BOOKS
?category=books400Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam com.example.demo.product.Category] for value [books]
?category=400Required 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:

Java
@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;
}
Bash
curl -X POST http://localhost:8117/lab/form -d "name=Desk+Lamp&price=24.00"
Text
name=Desk Lamp, price=24.00

Vớ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 requireddefaultValue:

Java
@GetMapping("/headers")
public String headers(@RequestHeader("X-Request-Id") String requestId,
                      @RequestHeader(name = "Accept-Language", defaultValue = "en") String language) {
    return "requestId=" + requestId + ", language=" + language;
}
Bash
curl http://localhost:8117/lab/headers -H "X-Request-Id: 7f3a9c"
Text
requestId=7f3a9c, language=en
Bash
curl http://localhost:8117/lab/headers -H "x-request-id: 7f3a9c" -H "Accept-Language: vi-VN"
Text
requestId=7f3a9c, language=vi-VN

Request 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:

Java
@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:

Java
@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";
}
Bash
curl http://localhost:8117/lab/all-headers -H "X-Request-Id: 7f3a9c"
Text
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()headerSet(). Parameter HttpHeaders không annotation ở phần trước thì không nhận được header nào trong số này.

Java
@GetMapping("/theme")
public String theme(@CookieValue("theme") String theme) {
    return "theme=" + theme;
}
Bash
curl http://localhost:8117/lab/theme -b "theme=dark"
Text
theme=dark

@CookieValue cũng có các attribute requireddefaultValue. 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ó:

Bash
curl -i -X POST http://localhost:8117/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'
Text
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:

Bash
curl -i -X POST http://localhost:8117/api/products \
  -d '{"name":"Desk Lamp","price":24.00,"category":"ELECTRONICS"}'
Text
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"}
Text
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/jsonStatusLý do trong log
không có body, hoặc -d ''400HttpMessageNotReadableException: 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,400HttpMessageNotReadableException: JSON parse error: Unexpected end-of-input within/between Object entries
{"name":"Desk Lamp","price":"cheap","category":"ELECTRONICS"}400HttpMessageNotReadableException: 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:

Java
@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:

BuilderStatusDùng cho
ResponseEntity.ok(body)200giống như trả về body; dùng ok().header(…).body(body) khi cần thêm header
ResponseEntity.created(uri).body(body)201resource mới; uri trở thành header Location
ResponseEntity.noContent().build()204thành công mà không có gì để gửi lại
ResponseEntity.notFound().build()404resource 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 404tra 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:

Java
@PostMapping("/imports")
public ResponseEntity<Map<String, String>> startImport() {
    return ResponseEntity.status(HttpStatus.ACCEPTED)
            .header("X-Import-Id", "imp-42")
            .body(Map.of("status", "QUEUED"));
}
Bash
curl -i -X POST http://localhost:8117/lab/imports
Text
HTTP/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-EncodingDate 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:

Java
@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);
}
Bash
curl -i "http://localhost:8117/lab/featured?empty=true"
Text
HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:01:33 GMT

Khô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:

Java
@GetMapping("/which-status")
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<String> whichStatus() {
    return ResponseEntity.ok("ResponseEntity said 200");
}
Bash
curl -i http://localhost:8117/lab/which-status
Text
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 23
Date: Sat, 12 Sep 2026 07:01:33 GMT
 
ResponseEntity said 200

ResponseEntity 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 @ResponseStatus khi 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ần Location.
  • Trả về ResponseEntity khi 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èm Location chứ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:

src/main/java/com/example/demo/product/ProductController.java
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ì:

  • findAll khai báo cả hai bộ lọc là required = false, nên GET /api/products khô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ác null. category là enum, nên ?category=books là lỗi 400, như ở phần về enum.
  • findById dùng ResponseEntity.of, biến một Optional rỗng thành 404.
  • create nhận một header X-Request-Id tù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.
  • delete dùng giá trị trả về của Map.remove, là null khi không có gì để xóa.

POST trả về 201 Created kèm header Location

Bash
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"}'
Text
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"}

ResponseEntity.created(location).header("X-Request-Id", …).body(product) được nối từng lời gọi tới status line 201, hai header Location và X-Request-Id, và body JSON của response thật

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 URIcreated() 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 đó:

Text
HTTP/1.1 201
Location: http://localhost:8117/api/products/5
X-Request-Id: 2e37d6d3-49bb-4883-bdb7-97129cdf42ca

fromCurrentRequest() 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:

Java
@PostMapping("/uris")
public String uris() {
    return "fromCurrentRequest()    -> "
            + ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(6).toUri()
            + "\nfromCurrentRequestUri() -> "
            + ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{id}").buildAndExpand(6).toUri()
            + "\n";
}
Text
fromCurrentRequest()    -> http://localhost:8117/lab/uris/6?source=import
fromCurrentRequestUri() -> http://localhost:8117/lab/uris/6

GET trả về 404 khi sản phẩm không tồn tại

Bash
curl -i http://localhost:8117/api/products/99
Text
HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:05:08 GMT

GET /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

Bash
curl -i -X DELETE http://localhost:8117/api/products/4
Text
HTTP/1.1 204
Date: Sat, 12 Sep 2026 07:05:08 GMT

Chạy lại đúng lệnh đó lần thứ hai:

Text
HTTP/1.1 404
Content-Length: 0
Date: Sat, 12 Sep 2026 07:05:08 GMT

GET 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.

Bash
curl -i "http://localhost:8117/api/products?category=BOOKS"
Text
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"}]
Bash
curl "http://localhost:8117/api/products?minPrice=20"
Text
[{"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"}]
Bash
curl "http://localhost:8117/api/products?category=ELECTRONICS&minPrice=50"
Text
[{"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:

AnnotationGiá trị lấy từMặc định bắt buộcKhi thiếu giá trịKhi không convert được
@PathVariablemột đoạn {name} trong pattern của mapping500, MissingPathVariableException: mapping không khai báo variable đó400, MethodArgumentTypeMismatchException
@RequestParamquery string, và các field của body dạng form400, MissingServletRequestParameterException400, MethodArgumentTypeMismatchException
@RequestHeadermột header của request, tên so khớp không phân biệt hoa thường400, MissingRequestHeaderException400, MethodArgumentTypeMismatchException
@CookieValuemột cookie400, MissingRequestCookieException400, MethodArgumentTypeMismatchException
@RequestBodytoàn bộ body, đọc bởi message converter chấp nhận Content-Type của nó400, HttpMessageNotReadableException400, 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@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.

Bài viết liên quan

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator 9.1.3, kiểm chứng bằng các lần chạy thật: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Xử lý exception tập trung trong Spring Boot: @RestControllerAdvice, @ExceptionHandler và ProblemDetail

Xử lý exception tập trung trong Spring Boot 4.1.1, kiểm chứng trên project thật: body /error mặc định và BasicErrorController, spring.web.error.* thay cho server.error.*, @ResponseStatus và ResponseStatusException, @ExceptionHandler trong controller và trong @RestControllerAdvice, cách Spring chọn một handler theo khoảng cách type, controller, @Order và cause, ProblemDetail (RFC 9457) với application/problem+json, ErrorResponseException, spring.mvc.problemdetails.enabled, ResponseEntityExceptionHandler trả 422 kèm danh sách lỗi theo field, và một handler catch-all giữ nguyên các response 4xx của framework.

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi 3.1.1 trên Spring Boot 4.1.1, kiểm chứng trên jar đang chạy: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

JSON trong Spring Boot 4.1.1 với Jackson 3.1.5, kiểm chứng trên project thật: JacksonJsonHttpMessageConverter và bean jacksonJsonMapper, package tools.jackson, JsonMapper immutable và exception unchecked, đo các giá trị mặc định của Jackson 3 so với use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enum và Optional, record, @JsonAlias và @JsonCreator, property spring.jackson và JsonMapperBuilderCustomizer, vì sao DTO tốt hơn để lộ entity, map bằng tay và MapStruct 1.6.3 với Gradle và Maven.