Chuyện Gì Đang Xảy Ra
Bạn gọi một REST endpoint và nhận được HTTP 415 Unsupported Media Type với thông báo sau trong logs:
org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded' not supported
Spring đang báo cho bạn biết: Content-Type mà client gửi lên không khớp với những gì controller mong đợi. Endpoint của bạn dùng @RequestBody, yêu cầu JSON (hoặc định dạng có cấu trúc khác). Client lại đang gửi dữ liệu dạng form-encoded — cùng định dạng mà trình duyệt dùng khi submit HTML form thông thường.
Có ba tình huống thường xuyên gây ra lỗi này: bạn đang test bằng một công cụ mặc định dùng form encoding, một frontend developer đang gọi sai content type, hoặc ai đó đã chuyển một MVC controller cũ sang REST controller mà không cập nhật định dạng request.
Tái Hiện và Xác Định Nguyên Nhân Gốc Rễ
Khoanh vùng sự không khớp bằng cách kiểm tra hai điều sau.
1. Client đang gửi gì
Lệnh curl này trông vô hại nhưng mặc định gửi dữ liệu dạng form-encoded:
curl -X POST http://localhost:8080/api/users \
-d "name=Alice&email=alice@example.com"
@RequestBody của Spring không thể deserialize dữ liệu đó. Không có converter nào được đăng ký cho form data trên các endpoint dùng @RequestBody — đó là nguyên nhân gây ra lỗi 415.
Đang dùng Postman hoặc Insomnia? Kiểm tra tab Body. Nếu được đặt thành form-data hoặc x-www-form-urlencoded, đó chính là vấn đề của bạn.
2. Controller mong đợi gì
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<User> createUser(@RequestBody UserRequest request) {
// @RequestBody yêu cầu Content-Type: application/json
return ResponseEntity.ok(userService.create(request));
}
}
@RequestBody yêu cầu Spring deserialize HTTP body thông qua một HttpMessageConverter. Spring Boot tự động đăng ký Jackson cho JSON. Dữ liệu form-encoded không có converter tương ứng — đó là lý do gây ra 415.
Fix 1 — Thay Đổi Client Để Gửi JSON (Cách Fix Phổ Biến Nhất)
Nếu controller đúng và bạn đang xây dựng một REST API chuẩn, hãy sửa phía client.
curl
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
Postman / Insomnia
- Mở tab Body
- Chọn raw
- Chọn JSON từ dropdown
- Dán JSON payload của bạn vào
JavaScript (fetch API)
fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' })
});
Axios
// Axios tự động đặt Content-Type: application/json khi bạn truyền vào một plain object
axios.post('/api/users', { name: 'Alice', email: 'alice@example.com' });
Một bẫy thường gặp với Axios: bọc dữ liệu trong qs.stringify() hoặc URLSearchParams sẽ âm thầm chuyển nó trở lại thành form encoding. Đây là nguồn gốc gây nhầm lẫn phổ biến khi nhìn lệnh Axios có vẻ đúng ngay từ đầu.
Fix 2 — Thay Đổi Controller Để Chấp Nhận Form Data
Một số tích hợp gửi các POST request dạng form-encoded theo thiết kế — payment gateway, OAuth callback, legacy webhook. Trong những trường hợp đó, hãy thay @RequestBody bằng @RequestParam:
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<User> createUserFromForm(
@RequestParam String name,
@RequestParam String email) {
return ResponseEntity.ok(userService.create(name, email));
}
Có DTO với hàng chục fields? Dùng @ModelAttribute thay thế:
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<User> createUserFromForm(@ModelAttribute UserRequest request) {
return ResponseEntity.ok(userService.create(request));
}
Thuộc tính consumes có tác dụng kép: nó ràng buộc media type được chấp nhận và làm cho API tự mô tả. Spring sẽ trả về 415 rõ ràng cho bất kỳ caller nào gửi sai định dạng.
Fix 3 — Hỗ Trợ Cả JSON Lẫn Form Encoding
Hiếm gặp, nhưng đôi khi bạn cần xây dựng một hybrid endpoint có thể chấp nhận cả hai định dạng — ví dụ, một login endpoint được dùng bởi cả mobile app (JSON) và form server-side cũ (form-encoded):
@PostMapping(
value = "/users",
consumes = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE }
)
public ResponseEntity<User> createUser(
@RequestBody(required = false) UserRequest jsonRequest,
@ModelAttribute(binding = false) UserRequest formRequest,
@RequestHeader("Content-Type") String contentType) {
UserRequest request = contentType.contains("application/json") ? jsonRequest : formRequest;
return ResponseEntity.ok(userService.create(request));
}
Cách này hoạt động, nhưng nhanh chóng trở nên lộn xộn. Hai endpoint riêng biệt — một cho REST client, một cho form-based client — thường gọn gàng hơn và dễ test hơn.
Fix 4 — Kiểm Tra Spring Security Filter (Lỗi 415 Bất Ngờ)
Đôi khi lỗi 415 xuất hiện dù client của bạn chắc chắn đang gửi JSON. Spring Security thường là thủ phạm. CSRF filter của nó có thể tiêu thụ request body trước khi controller nhìn thấy nó, đặc biệt trong Spring Security 5.x/6.x. Một filter phía trước trong chain cũng có thể đang viết lại headers.
Đối với stateless REST API, hãy vô hiệu hóa CSRF:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // stateless REST — không cần CSRF
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
}
Vẫn không chắc dữ liệu gì đang đến controller? Thêm một logging filter để xem Content-Type thô trước khi Spring xử lý:
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
log.info("Content-Type: {}", request.getContentType());
filterChain.doFilter(request, response);
}
}
Kiểm Tra
Chạy lệnh này và xác nhận bạn nhận được phản hồi 2xx:
curl -v -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
Flag -v hiển thị toàn bộ response. Tìm HTTP/1.1 200 OK hoặc 201 Created. Không còn lỗi 415 nữa.
Đối với integration test, hãy bao gồm cả happy path lẫn trường hợp bị từ chối:
@Test
void createUser_withJsonBody_returns201() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"Alice\", \"email\": \"alice@example.com\"}"))
.andExpect(status().isCreated());
}
@Test
void createUser_withFormEncoding_returns415() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "Alice")
.param("email", "alice@example.com"))
.andExpect(status().isUnsupportedMediaType());
}
Mẹo
Đang debug vấn đề Content-Type giữa các service? URL encoding có thói quen len lỏi vào những nơi không ngờ tới — một middleware layer, một proxy, hoặc một thư viện làm sai. URL Encoder/Decoder tại ToolCraft cho phép bạn nhanh chóng giải mã những gì một third-party webhook đang thực sự gửi, mà không cần chuyển tiếp dữ liệu của bạn đi đâu cả.
Một vài thói quen giúp ngăn chặn hoàn toàn loại lỗi này:
- Luôn đặt
consumestrên@PostMapping. Nó biến một lỗi 400 mơ hồ thành 415 rõ ràng với thông báo hữu ích. - Thêm một integration test cho mỗi endpoint để xác minh Content-Type đúng được thực thi — mất 10 phút, tiết kiệm hàng giờ debug.
- Tích hợp Springdoc OpenAPI (người kế nhiệm của Springfox). Nó hiển thị các media type được chấp nhận trong Swagger UI, giúp frontend developer biết cần gửi gì trước khi viết request đầu tiên.
Vấn Đề Cốt Lõi
HTTP 415 là vi phạm hợp đồng. Controller khai báo những gì nó chấp nhận; client phải tuân theo. @RequestBody nghĩa là JSON. Form data cần @RequestParam hoặc @ModelAttribute. Cách fix hầu như luôn chỉ một dòng — nhưng tìm ra dòng nào đòi hỏi phải biết sự không khớp nằm ở đâu. Hãy khai báo rõ ràng consumes ngay từ đầu, và lỗi sẽ xuất hiện ngay lập tức với thông báo rõ ràng thay vì biến dạng thành lỗi 400 hoặc 500 khó hiểu ở đâu đó phía sau.

