Content Negotiation, API Versioning, và CORS — Spring MVC routing nâng cao
Bài này bóc 3 cơ chế quyết định HTTP request nào đến handler nào: content negotiation qua produces/consumes, API versioning (URL/header/media-type) và tại sao cần versioning, match specificity order, @CrossOrigin và cấu hình CORS production.
TL;DR: Spring MVC chọn handler qua 2 lớp filter: produces (khớp Accept header → 406 nếu miss) và consumes (khớp Content-Type → 415 nếu miss). Khi nhiều handler cùng match, Spring áp specificity order — literal segment thắng path variable thắng wildcard. API versioning giải quyết bài toán breaking change không làm vỡ client cũ: path versioning (/api/v1/) phổ biến nhất vì cache-friendly và debug dễ; header/media-type versioning "RESTful hơn" nhưng khó debug. @CrossOrigin config CORS per-controller; production nên tập trung vào 1 CorsConfigurationSource global.
Bài 03 — @RestController & mapping đã trình bày 7 attribute của @RequestMapping và URL pattern syntax. Bài này đào sâu cơ chế runtime của 2 attribute quan trọng nhất (produces/consumes), lý do tồn tại của versioning, và CORS — 3 khía cạnh quyết định API contract production.
1. Content negotiation — vì sao cần và cơ chế hoạt động
1.1 Bài toán: cùng resource, nhiều định dạng
Giả sử team build API order management. Client web frontend cần JSON; hệ thống kế toán legacy cần XML; client mobile cần JSON nén. Cùng resource /api/orders/42 — 3 cách biểu diễn khác nhau.
Giải pháp naive: tạo 3 URL khác nhau — /api/orders/42/json, /api/orders/42/xml, /api/orders/42/mobile. Đây vi phạm nguyên tắc REST cơ bản: URL identifies resource, không identifies representation. Resource "order 42" là một; cách biểu diễn là biến số.
Content negotiation (thương lượng định dạng) là cơ chế HTTP (RFC 7231) cho phép client nói "tôi muốn định dạng X" và server chọn handler phù hợp — cùng URL, nhiều handler khác nhau theo format.
Spring MVC implement content negotiation qua 2 attribute của @RequestMapping:
produces: filter handler theoAcceptheader của request.consumes: filter handler theoContent-Typeheader của request.
1.2 produces — filter theo Accept header
Accept header (RFC 7231 §5.3.2) là client nói: "tôi chấp nhận nhận lại định dạng này". Server đọc header đó, chọn handler có produces khớp, serialize response đúng format.
// Cung URL /api/orders/{id} — khac produces
@GetMapping(value = "/api/orders/{id}",
produces = MediaType.APPLICATION_JSON_VALUE)
public OrderDto getJson(@PathVariable Long id) {
return orderService.findById(id);
}
@GetMapping(value = "/api/orders/{id}",
produces = MediaType.APPLICATION_XML_VALUE)
public OrderDto getXml(@PathVariable Long id) {
return orderService.findById(id);
}
Request flow:
GET /api/orders/42
Accept: application/json
RequestMappingHandlerMapping match URL + HTTP method → tìm thấy 2 handler → check produces condition → chọn getJson. Response: JSON body.
GET /api/orders/42
Accept: application/xml
Chọn getXml. Response: XML body (cần jackson-dataformat-xml dependency).
GET /api/orders/42
Accept: text/csv
Không handler nào có produces = "text/csv" → Spring trả 406 Not Acceptable. Client nhận 406 biết ngay: server không hỗ trợ format này, không phải lỗi logic.
Tại sao Spring chọn 406 thay 404? Vì resource tồn tại (order 42 có trong DB) — chỉ không có representation phù hợp. 404 nói "resource không tồn tại" — sai về ngữ nghĩa HTTP. 406 nói "resource có nhưng tôi không serve được format bạn muốn" — đúng.
1.3 consumes — filter theo Content-Type header
Content-Type header mô tả định dạng của request body mà client gửi lên. consumes filter handler theo header này.
// Cung URL + method — khac consumes
@PostMapping(value = "/api/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String uploadFile(@RequestParam("file") MultipartFile file) {
return storageService.store(file);
}
@PostMapping(value = "/api/upload",
consumes = MediaType.APPLICATION_JSON_VALUE)
public String uploadJson(@RequestBody UploadRequest req) {
return storageService.storeFromBase64(req.content());
}
Client browser gửi form upload:
POST /api/upload
Content-Type: multipart/form-data; boundary=----...
Spring chọn uploadFile. Client gửi JSON với base64:
POST /api/upload
Content-Type: application/json
Spring chọn uploadJson. Gửi Content-Type: text/plain → 415 Unsupported Media Type.
Vì sao 415 thay 400? 400 Bad Request dùng cho request body sai schema/format cụ thể. 415 dùng khi loại media type không được hỗ trợ — server từ chối xử lý trước khi đọc body. Phân biệt rõ giúp client debug nhanh.
1.4 Cơ chế bên dưới — RequestMappingHandlerMapping
RequestMappingHandlerMapping (lớp core Spring MVC) build một MappingRegistry lúc startup: map mỗi handler method → RequestMappingInfo (chứa URL pattern + method + produces + consumes + headers + params conditions).
Khi request đến, lookupHandlerMethod chạy qua danh sách RequestMappingInfo và gọi getMatchingCondition(request) trên từng entry. ProducesRequestCondition.getMatchingCondition so sánh Accept header với produces attribute — nếu có overlap → match; nếu không → loại. Tương tự ConsumesRequestCondition cho Content-Type.
flowchart TB REQ["HTTP Request"] URL["URL + Method match"] CONS["ConsumesCondition<br/>Content-Type khop consumes?"] PROD["ProducesCondition<br/>Accept khop produces?"] PARAMS["Params + Headers condition"] HANDLER["Handler selected"] E415["415 Unsupported Media Type"] E406["406 Not Acceptable"] REQ --> URL URL --> CONS CONS -->|"khong match"| E415 CONS -->|"match"| PROD PROD -->|"khong match"| E406 PROD -->|"match"| PARAMS PARAMS --> HANDLER
Lớp filter diễn ra trước khi argument binding và handler invocation — nghĩa là controller code không chạy nếu request không vượt qua content negotiation. Đây là fail-fast đúng chỗ.
2. Match specificity order — khi nhiều handler cùng khớp
2.1 Bài toán: literal vs variable
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}") // (A) path variable
public OrderDto findById(@PathVariable Long id) { ... }
@GetMapping("/latest") // (B) literal segment
public OrderDto findLatest() { ... }
@GetMapping("/export") // (C) literal segment khac
public byte[] export() { ... }
}
Request GET /api/orders/latest: cả (A) và (B) đều match về URL. Spring phải chọn 1.
2.2 Specificity order — từ cụ thể đến tổng quát
Spring sắp xếp các handler match theo specificity (độ đặc hiệu) và chọn handler có điểm cao nhất. Thứ tự từ cụ thể nhất đến tổng quát nhất:
flowchart TB
L1["1. Literal segment<br/>/api/orders/latest -- cao nhat"]
L2["2. Path variable co regex<br/>/api/orders/{id:\\d+}"]
L3["3. Path variable khong constraint<br/>/api/orders/{id}"]
L4["4. Wildcard single segment<br/>/api/orders/*"]
L5["5. Wildcard multi segment<br/>/api/orders/**"]
L1 --> L2 --> L3 --> L4 --> L5Request GET /api/orders/latest: handler (B) là literal → thắng. id không bao giờ nhận giá trị "latest".
Request GET /api/orders/42: chỉ (A) match (42 là digits, không match literal "latest" hoặc "export") → (A) thắng.
Tại sao cần specificity thay "first wins"? Nếu Spring dùng "thứ tự khai báo trong class", developer phải nhớ đặt literal trước variable — lỗi dễ xảy ra khi refactor. Specificity-based selection không phụ thuộc thứ tự khai báo → code không giòn, Spring tự sort đúng.
2.3 Regex constraint — thêm một lớp đặc hiệu
@GetMapping("/api/orders/{id:\\d+}") // chi match digits
public OrderDto findByNumericId(@PathVariable Long id) { ... }
@GetMapping("/api/orders/{slug:[a-z-]+}") // chi match slug
public OrderDto findBySlug(@PathVariable String slug) { ... }
Request /api/orders/42 → match \d+, không match [a-z-]+ → findByNumericId.
Request /api/orders/my-order → match [a-z-]+, không match \d+ → findBySlug.
Regex constraint có specificity cao hơn path variable không constraint. Nếu cả 2 đều match một request (ví dụ regex quá rộng), Spring chọn handler có regex trước. Nếu thực sự ambiguous và không phân biệt được → IllegalStateException tại startup (không đợi đến runtime).
2.4 Phân biệt qua produces/consumes — cùng URL, handler khác
Khi 2 handler cùng URL pattern và HTTP method, produces/consumes là cơ chế phân biệt hợp lệ (Spring không throw IllegalStateException):
@GetMapping(value = "/api/orders/{id}",
produces = "application/vnd.olhub.v1+json")
public OrderDtoV1 getV1(@PathVariable Long id) { ... }
@GetMapping(value = "/api/orders/{id}",
produces = "application/vnd.olhub.v2+json")
public OrderDtoV2 getV2(@PathVariable Long id) { ... }
Đây là media type versioning — mỗi version là một media type khác nhau. Specificity tính riêng trong ProducesRequestCondition.
3. API versioning — tại sao cần và 3 strategy
3.1 Vấn đề: breaking change trong production
API public là contract. Khi 100 client đang gọi GET /api/orders/{id} và nhận:
{ "id": 42, "total": 150000, "customer": "Nguyen Van A" }
Nếu team đổi schema:
{
"id": 42,
"totalAmount": 150000,
"customerInfo": { "name": "Nguyen Van A", "email": "[email protected]" }
}
Field total → totalAmount, customer → customerInfo object: breaking change. Client cũ parse response.total sẽ nhận undefined. 100 client cần update đồng thời — không khả thi.
API versioning cho phép server chạy nhiều version song song. Client cũ gọi v1 (schema cũ), client mới gọi v2 (schema mới). Team migrate dần — không bao giờ big-bang cut-off.
Đây là lý do tồn tại của versioning: không phải để làm đẹp URL, mà để không phá contract đang chạy của client cũ trong khi tiến hóa API.
3.2 Path versioning — phổ biến nhất
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
@GetMapping("/{id}")
public OrderDtoV1 findById(@PathVariable Long id) {
return orderService.findByIdV1(id);
}
}
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
@GetMapping("/{id}")
public OrderDtoV2 findById(@PathVariable Long id) {
return orderService.findByIdV2(id);
}
}
Client v1: GET /api/v1/orders/42 → OrderDtoV1 (schema cũ).
Client v2: GET /api/v2/orders/42 → OrderDtoV2 (schema mới).
Cả 2 chạy song song — không cần client v1 migrate ngay.
Ưu điểm:
- URL nói rõ version — debug bằng curl/browser không cần set header.
- CDN cache theo URL natively — không cần
Varyheader. - Swagger/OpenAPI generate doc riêng cho
/api/v1/và/api/v2/. - API gateway (Kong, AWS API Gateway) route theo path prefix đơn giản.
Nhược điểm:
- URL "ô nhiễm" —
/api/v1/ordersthay vì/api/orders. - Controller class duplicate khi v1 và v2 share 80% logic. Mitigate bằng shared service layer: cả 2 controller gọi cùng
OrderService, chỉ DTO khác.
// Share logic, khac DTO
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
private final OrderService orderService; // same service as v1
@GetMapping("/{id}")
public OrderDtoV2 findById(@PathVariable Long id) {
Order order = orderService.findById(id); // same call
return OrderDtoV2.from(order); // khac mapper
}
}
3.3 Header versioning
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public OrderDtoV1 findByIdV1(@PathVariable Long id) { ... }
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public OrderDtoV2 findByIdV2(@PathVariable Long id) { ... }
}
Client gửi header X-API-Version: 2 → Spring match handler có headers = "X-API-Version=2".
Ưu điểm: URL ổn định — GET /api/orders/42 không đổi giữa các version. Dễ deprecate: đổi default version qua config mà không đổi URL.
Nhược điểm: Không debug được bằng browser URL bar. CDN cần cấu hình Vary: X-API-Version. Swagger/OpenAPI khó phân tách 2 version cùng path.
3.4 Media type versioning
@GetMapping(value = "/api/orders/{id}",
produces = "application/vnd.olhub.v1+json")
public OrderDtoV1 findByIdV1(@PathVariable Long id) { ... }
@GetMapping(value = "/api/orders/{id}",
produces = "application/vnd.olhub.v2+json")
public OrderDtoV2 findByIdV2(@PathVariable Long id) { ... }
Client gửi Accept: application/vnd.olhub.v2+json → Spring route đến handler v2.
Đây là cách "RESTful purity" nhất — version nằm trong media type, URL thuần biểu diễn resource. Tuy nhiên hiếm gặp trong practice: client code phức tạp hơn, tooling hỗ trợ kém, log khó đọc.
3.5 So sánh 3 strategy
| Tiêu chí | Path /api/v1/ | Header X-API-Version | Media type vnd.v1+json |
|---|---|---|---|
| Phổ biến | Cao (~80% production) | Trung bình | Thấp (purist) |
| Debug curl/browser | Dễ | Cần set header | Cần set header |
| Cache CDN | URL-native | Cần Vary header | Cần Vary header |
| Swagger/OpenAPI | Tự nhiên (2 path riêng) | Khó phân tách | Khó phân tách |
| RESTful purity | Thấp (URL có version) | Cao | Cao nhất |
| Khuyến nghị 2026 | Default choice | Internal API | Edge case |
Kết luận thực tế: path versioning cho API public — rõ ràng, cache-friendly, tooling hỗ trợ tốt. Header versioning cho internal API trong organization nơi không muốn expose version trong URL. Media type versioning khi team có constraint RESTful strict — hiếm.
3.6 Deprecation header — thông báo migration
Khi cần sunset v1:
@GetMapping("/api/v1/orders/{id}")
public ResponseEntity<OrderDtoV1> findByIdV1(@PathVariable Long id) {
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "2027-06-30")
.header("Link", "</api/v2/orders/" + id + ">; rel=\"successor-version\"")
.body(orderService.findByIdV1(id));
}
Client tooling (Postman, API linting) monitor Deprecation header và cảnh báo dev. Sunset header (RFC 8594) nói ngày cụ thể API ngừng hoạt động. Link rel successor-version chỉ URL thay thế.
Pattern này cho phép sunset có lịch — không bao giờ bất ngờ cắt client.
4. @CrossOrigin và CORS
4.1 Vấn đề: Same-Origin Policy
Same-Origin Policy (SOP) là cơ chế bảo mật browser: script trên https://olhub.org không được gọi https://api.olhub.org nếu origin khác (scheme, host, hoặc port khác nhau). Mục đích: ngăn trang độc hại đọc dữ liệu từ API của user đang đăng nhập.
CORS (Cross-Origin Resource Sharing, Fetch Standard + RFC 6454) là cơ chế cho phép server opt-in — "tôi tin tưởng origin X, cho phép script từ X gọi API của tôi". Browser đọc response header Access-Control-Allow-Origin và quyết định có cho JS đọc response không.
Không có CORS config: browser gửi request nhưng chặn JS đọc response. Server nhận được request (log thấy), nhưng JS phía client bị block — 2 triệu chứng confusing nếu không hiểu SOP.
4.2 @CrossOrigin per-controller
@RestController
@RequestMapping("/api/orders")
@CrossOrigin(
origins = {"https://olhub.org", "https://app.olhub.org"},
methods = {RequestMethod.GET, RequestMethod.POST},
allowedHeaders = {"Authorization", "Content-Type"},
maxAge = 3600
)
public class OrderController {
@GetMapping("/{id}")
public OrderDto findById(@PathVariable Long id) { ... }
@PostMapping
@CrossOrigin(origins = "*") // override class-level cho endpoint nay
public OrderDto create(@RequestBody OrderRequest req) { ... }
}
@CrossOrigin trên class → apply cho mọi method. @CrossOrigin trên method → override class-level cho method đó.
Spring tự generate response header Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers. Pre-flight OPTIONS request được handle tự động — không cần viết handler.
Cảnh báo: origins = "*" với allowCredentials = true là security violation — browser từ chối (spec cấm tổ hợp này vì cho phép credential bất kỳ origin là lỗ hổng CSRF).
4.3 CORS global — production pattern
@CrossOrigin rải trên 50 controller khó audit — dễ miss controller mới, khó thay đổi whitelist. Production nên tập trung 1 chỗ:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://olhub.org", "https://app.olhub.org")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("Authorization", "Content-Type", "X-Request-Id")
.exposedHeaders("X-Request-Id", "X-Total-Count")
.allowCredentials(true)
.maxAge(3600);
}
}
Khi dùng cùng Spring Security, cấu hình qua CorsConfigurationSource để Spring Security filter chain respect CORS trước khi authenticate:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://olhub.org", "https://app.olhub.org"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
Tại sao CORS phải cấu hình trước Spring Security filter? Pre-flight OPTIONS request browser gửi không có Authorization header — nếu Spring Security authenticate trước, OPTIONS bị 401, browser không bao giờ biết CORS có cho phép không → request thật không được gửi. Đặt CorsFilter trước auth filter (hoặc dùng http.cors(...)) giải quyết vòng lặp này.
5. Pitfall tổng hợp
❌ Nhầm 1 — quên consumes, expect 415 sớm:
// Thieu consumes
@PostMapping("/api/upload")
public String upload(@RequestBody UploadRequest req) { ... }
Client gửi Content-Type: text/plain → request vẫn match handler (không có consumes để lọc) → Spring cố bind @RequestBody → HttpMessageNotReadableException runtime, log confusing.
// Them consumes -- 415 ro rang truoc khi run handler
@PostMapping(value = "/api/upload", consumes = MediaType.APPLICATION_JSON_VALUE)
public String upload(@RequestBody UploadRequest req) { ... }
❌ Nhầm 2 — đặt origins = "*" với allowCredentials = true:
@CrossOrigin(origins = "*", allowCredentials = "true") // security violation
Browser theo spec Fetch Standard §3.2.5 từ chối response nếu Access-Control-Allow-Origin: * + Access-Control-Allow-Credentials: true. Spring ném IllegalArgumentException tại startup từ Boot 2.4+.
✅ Luôn explicit origin list khi cần credentials:
@CrossOrigin(origins = {"https://olhub.org"}, allowCredentials = "true")
❌ Nhầm 3 — tưởng specificity phụ thuộc thứ tự khai báo:
@RestController
public class OrderController {
@GetMapping("/api/orders/{id}") // khai bao truoc
public OrderDto findById(...) { ... }
@GetMapping("/api/orders/latest") // khai bao sau
public OrderDto findLatest() { ... }
}
Một số developer đặt findLatest trước findById để "đảm bảo match". Không cần — Spring tự sort theo specificity. Thứ tự khai báo không ảnh hưởng. Đặt theo logic đọc code dễ hiểu.
❌ Nhầm 4 — mix @CrossOrigin per-controller với CORS global, confusing behavior:
Nếu WebMvcConfigurer.addCorsMappings config allowedOrigins("https://a.com") nhưng một controller có @CrossOrigin(origins = "https://b.com"), controller đó override global config riêng cho mình. Kết quả: CORS behavior khác nhau tùy controller — khó audit security. Chọn 1 approach và stick to it.
❌ Nhầm 5 — bỏ qua Deprecation header khi sunset API version:
Tắt /api/v1/ mà không thông báo qua Deprecation/Sunset header → client production bị break đột ngột. Luôn thêm header deprecation ít nhất 3 tháng trước sunset.
Cơ chế bên dưới — luồng đầy đủ từ request đến handler
Gộp lại, khi một HTTP request đến, RequestMappingHandlerMapping chạy qua pipeline sau:
flowchart TB REQ["HTTP Request vao DispatcherServlet"] URLM["1. URL + HTTP Method match<br/>Loai handler URL/method khong hop"] CONS["2. ConsumesCondition<br/>Content-Type khop consumes?"] PROD["3. ProducesCondition<br/>Accept khop produces?"] HEAD["4. HeadersCondition + ParamsCondition<br/>header/query param match?"] SPEC["5. Sort by specificity<br/>Literal > Regex > Variable > Wildcard"] BEST["Best match handler selected"] INVOKE["Handler invoked<br/>argument binding + method call"] REQ --> URLM --> CONS --> PROD --> HEAD --> SPEC --> BEST --> INVOKE
Mỗi bước loại bớt candidate. Bước 5 sort xảy ra sau khi tất cả conditions lọc xong — chỉ sort trong tập còn lại.
Nếu sau bước 5 vẫn còn 2 handler điểm specificity bằng nhau và không phân biệt được → IllegalStateException tại startup (Spring fail fast). Đây là lý do cần đảm bảo mỗi combination (URL, method, produces, consumes, headers, params) là unique trong cả app.
Liên hệ các bài khác
- 03 — @RestController & mapping: bài đó trình bày toàn bộ 7 attribute
@RequestMappingvà URL pattern syntax — đây là nền tảng. Bài này đào sâu cơ chế runtime củaproduces/consumesvà thêm versioning/CORS mà bài 03 chỉ giới thiệu qua. - Request binding: sau khi handler được chọn qua content negotiation, Spring bind argument (
@PathVariable,@RequestParam,@RequestBody). Bài đó bóc cơ chếHttpMessageConverter— cùng component serialize/deserialize JSON theoproduces/consumes. - Spring Security CORS (module Security): khi thêm Spring Security, CORS filter phải đặt trước authentication filter — bài Security module giải thích thứ tự filter chain và cách
http.cors(...)tích hợpCorsConfigurationSource.
Tóm tắt
- Content negotiation:
producesfilter theoAcceptheader (mismatch → 406);consumesfilter theoContent-Type(mismatch → 415). Cơ chế chạy trongRequestMappingHandlerMappingtrước argument binding. - Specificity order (cao đến thấp): literal segment → path variable có regex → path variable không constraint → wildcard. Không phụ thuộc thứ tự khai báo — Spring tự sort. Ambiguous mapping →
IllegalStateExceptiontại startup. - API versioning tồn tại vì breaking change không thể deploy tất cả client đồng thời. 3 strategy: path (
/api/v1/) phổ biến nhất; header (X-API-Version) URL stable; media type (vnd.v1+json) RESTful purity. - Path versioning: URL visible, cache-friendly, tooling hỗ trợ tốt. Nhược: URL có version, controller duplicate (mitigate bằng shared service layer).
- Deprecation header (
Deprecation: true,Sunset: <date>) thông báo migration có lịch — không bao giờ bất ngờ cut client. - CORS:
@CrossOriginper-controller cho prototype/override;WebMvcConfigurer.addCorsMappingshoặcCorsConfigurationSourcecho production — 1 chỗ, dễ audit.origins = "*"+allowCredentials = true= security violation, browser từ chối.
Tự kiểm tra
Q1Endpoint GET /api/orders/{id} có 2 handler — một với produces = "application/json", một với produces = "application/xml". Client gửi Accept: text/csv. Spring trả HTTP status gì và tại sao không phải 404?▸
GET /api/orders/{id} có 2 handler — một với produces = "application/json", một với produces = "application/xml". Client gửi Accept: text/csv. Spring trả HTTP status gì và tại sao không phải 404?Spring trả 406 Not Acceptable.
Lý do không phải 404: resource "order id" tồn tại — handler match URL và HTTP method thành công. Vấn đề không phải "resource không có" mà là "server không có representation phù hợp với Accept: text/csv".
HTTP semantic: 404 = "resource không tồn tại". 406 = "resource có, không có representation phù hợp". Phân biệt đúng giúp client debug ngay: 406 nghĩa là "đổi Accept header hoặc dùng format được hỗ trợ", không phải "URL sai".
Cơ chế: ProducesRequestCondition.getMatchingCondition(request) so sánh danh sách media type trong Accept header với produces attribute. Không có overlap → condition return null → handler bị loại → không handler nào survive → Spring emit 406.
Q2Controller có 3 handler cùng path /api/orders/{id}: một với @GetMapping("/api/orders/{id}"), một với @GetMapping("/api/orders/{id:\\d+}"), một với @GetMapping("/api/orders/latest"). Request GET /api/orders/latest chọn handler nào? Request GET /api/orders/42 chọn handler nào? Giải thích theo specificity order.▸
/api/orders/{id}: một với @GetMapping("/api/orders/{id}"), một với @GetMapping("/api/orders/{id:\\d+}"), một với @GetMapping("/api/orders/latest"). Request GET /api/orders/latest chọn handler nào? Request GET /api/orders/42 chọn handler nào? Giải thích theo specificity order.Request GET /api/orders/latest: handler /api/orders/latest (literal segment) — specificity cao nhất. Cả {id} lẫn {id:\d+} đều match về pattern, nhưng literal thắng. \d+ không match "latest" (không phải digits) nên chỉ còn 2 candidate: literal và {id} → literal thắng.
Request GET /api/orders/42: handler /api/orders/{id:\d+} (path variable có regex) — specificity cao hơn path variable không constraint. "latest" và "export" không match "42". Regex \d+ match "42". Path variable {id} cũng match nhưng regex constraint cụ thể hơn → {id:\d+} thắng.
Specificity không phụ thuộc thứ tự khai báo trong class. Spring tính điểm từ RequestMappingInfo.compareTo và sort candidates sau khi filter conditions xong.
Q3Giải thích tại sao API versioning cần thiết. Team có REST API GET /api/orders/{id} trả {"id": 42, "total": 150000} — 50 client production đang dùng. Nếu đổi schema thành {"id": 42, "totalAmount": 150000} không versioning, chuyện gì xảy ra?▸
GET /api/orders/{id} trả {"id": 42, "total": 150000} — 50 client production đang dùng. Nếu đổi schema thành {"id": 42, "totalAmount": 150000} không versioning, chuyện gì xảy ra?Không versioning: đây là breaking change. Field total biến mất, client code parse response.total sẽ nhận undefined hoặc null. 50 client production bị break đồng thời ngay khi deploy.
Không thể yêu cầu 50 client update đồng thời — mỗi client có release cycle khác nhau, một số có thể là third-party không kiểm soát được.
Versioning giải quyết: server chạy song song /api/v1/orders/{id} (schema cũ) và /api/v2/orders/{id} (schema mới). Client cũ tiếp tục gọi v1 — không break. Client mới tích hợp v2. Migration diễn ra dần theo lịch — không bao giờ big-bang. Sunset v1 sau khi tất cả client đã migrate, thông báo trước qua Deprecation/Sunset header.
Q4Vì sao config @CrossOrigin(origins = "*", allowCredentials = "true") là security violation? Browser và spec không cho phép tổ hợp này vì lý do gì?▸
@CrossOrigin(origins = "*", allowCredentials = "true") là security violation? Browser và spec không cho phép tổ hợp này vì lý do gì?Tổ hợp này vi phạm Fetch Standard §3.2.5 (và RFC 6454). Lý do bảo mật:
Access-Control-Allow-Origin: * nghĩa là "mọi origin đều được phép". Access-Control-Allow-Credentials: true nghĩa là "cho phép gửi cookie/session/auth header". Nếu cho phép cả 2 cùng lúc, trang web độc hại bất kỳ (bất kỳ origin nào) có thể gửi request có credential đến API của bạn và đọc response — đây là tấn công CSRF cross-origin toàn diện.
Spec quy định: nếu credentials: include, Access-Control-Allow-Origin phải là một origin cụ thể (không phải *), và Access-Control-Allow-Credentials: true phải xuất hiện. Browser reject response nếu thấy tổ hợp * + credentials.
Spring Boot 2.4+ ném IllegalArgumentException tại startup nếu detect tổ hợp này trong CorsConfiguration. Fix: explicit origin list — setAllowedOrigins(List.of("https://olhub.org")).
Q5So sánh path versioning và header versioning theo 3 tiêu chí: debug dễ, cache CDN, RESTful purity. Khi nào chọn header versioning thay path versioning?▸
Debug dễ: Path versioning thắng — URL /api/v1/orders tự nói rõ version, curl/browser/log hiển thị trực tiếp. Header versioning cần set X-API-Version: 1 mỗi request — không thể test bằng URL thuần.
Cache CDN: Path versioning thắng — CDN cache theo URL key natively, /api/v1/orders và /api/v2/orders là 2 cache entry riêng biệt. Header versioning cần Vary: X-API-Version header — CDN hỗ trợ Vary phức tạp hơn và không phải CDN nào cũng cache theo header.
RESTful purity: Header versioning thắng — URL /api/orders stable, biểu diễn resource thuần túy. Path versioning đặt version trong URL vi phạm nguyên tắc "URL identifies resource" của Fielding.
Khi nào chọn header versioning:
- Internal API trong organization — không muốn expose version trong URL public.
- URL stability quan trọng — bookmark, webhook, CDN path đã cố định.
- Cần default version tự động: client cũ không gửi header → server dùng version mặc định, không cần client thay đổi gì.
Thực tế: ~80% production public API dùng path versioning vì tooling hỗ trợ tốt hơn. Header versioning là lựa chọn hợp lệ cho internal API.
Bài tiếp theo: Tổng kết module
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên