Spring Boot
Content Negotiation
Content negotiation is the mechanism by which Spring Boot selects the response format — JSON, XML, CSV, or any custom media type — based on the client's Accept header, URL suffix, or request parameter. This entry covers how Spring resolves media types, how to configure negotiation strategies, and how to register custom HttpMessageConverters.
How Spring Resolves Media Types
Spring evaluates three strategies in order to determine the requested media type: (1) a request parameter (e.g. ?format=json), (2) the Accept header, and (3) a path extension. By default only the Accept header strategy is active. Spring then walks the registered HttpMessageConverter list and picks the first converter that can write the negotiated type.
Java
// ── Default resolution order (Accept header only) ────────────────────
// GET /users/1
// Accept: application/json → MappingJackson2HttpMessageConverter → JSON
// Accept: application/xml → MappingJackson2XmlHttpMessageConverter → XML
// Accept: */* → first registered converter → JSON
// ── Enable parameter-based negotiation ───────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.favorParameter(true) // ?format=json or ?format=xml
.parameterName("format")
.ignoreAcceptHeader(false) // Accept header still respected
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("json", MediaType.APPLICATION_JSON)
.mediaType("xml", MediaType.APPLICATION_XML)
.mediaType("csv", new MediaType("text", "csv"));
}
}
// ── Example requests ──────────────────────────────────────────────────
// GET /users/1?format=json → JSON (parameter strategy)
// GET /users/1?format=xml → XML (parameter strategy)
// GET /users/1 → JSON (default, no Accept header)
// GET /users/1 Accept: application/xml → XML (Accept header strategy)produces and consumes on Mappings
The produces attribute restricts which media types a handler method will serve — returning 406 Not Acceptable if the client's Accept header does not match. The consumes attribute restricts which Content-Type a method accepts for request bodies — returning 415 Unsupported Media Type on mismatch. Both can be set at class or method level; method-level overrides class-level.
Java
@RestController
@RequestMapping(
value = "/api/v1/products",
produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
}
)
public class ProductController {
// ── Inherits class-level produces (JSON + XML) ────────────────────
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
return productService.findById(id);
}
// ── Override: this endpoint is JSON-only ──────────────────────────
@GetMapping(value = "/search",
produces = MediaType.APPLICATION_JSON_VALUE)
public Page<ProductResponse> search(@RequestParam String q, Pageable pageable) {
return productService.search(q, pageable);
}
// ── Accept JSON or XML request bodies ─────────────────────────────
@PostMapping(
consumes = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
},
produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
}
)
public ResponseEntity<ProductResponse> create(
@RequestBody @Valid CreateProductRequest request,
UriComponentsBuilder uriBuilder) {
ProductResponse created = productService.create(request);
URI location = uriBuilder
.path("/api/v1/products/{id}")
.buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
// ── CSV export — single custom media type ─────────────────────────
@GetMapping(value = "/export",
produces = "text/csv")
public ResponseEntity<String> export() {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=products.csv")
.body(productService.exportCsv());
}
}Registering a Custom HttpMessageConverter
Register a custom HttpMessageConverter to support any media type Spring does not handle out of the box — CSV, YAML, Protocol Buffers, and so on. Implement HttpMessageConverter<T>, declare the supported media types, and register it via WebMvcConfigurer. Spring will invoke it automatically when content negotiation selects your media type.
Java
// ── CSV converter for any List payload ───────────────────────────────
public class CsvHttpMessageConverter
extends AbstractHttpMessageConverter<List<?>> {
public CsvHttpMessageConverter() {
super(new MediaType("text", "csv"));
}
@Override
protected boolean supports(Class<?> clazz) {
return List.class.isAssignableFrom(clazz);
}
@Override
protected List<?> readInternal(Class<? extends List<?>> clazz,
HttpInputMessage inputMessage) {
throw new UnsupportedOperationException("CSV read not implemented");
}
@Override
protected void writeInternal(List<?> items,
HttpOutputMessage outputMessage) throws IOException {
try (PrintWriter writer = new PrintWriter(
new OutputStreamWriter(outputMessage.getBody(), StandardCharsets.UTF_8))) {
if (items.isEmpty()) return;
// Header row from field names via reflection
Field[] fields = items.get(0).getClass().getDeclaredFields();
writer.println(Arrays.stream(fields)
.map(Field::getName)
.collect(Collectors.joining(",")));
// Data rows
for (Object item : items) {
writer.println(Arrays.stream(fields)
.map(f -> {
f.setAccessible(true);
try { return String.valueOf(f.get(item)); }
catch (IllegalAccessException e) { return ""; }
})
.collect(Collectors.joining(",")));
}
}
}
}
// ── Register the converter ────────────────────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new CsvHttpMessageConverter());
}
}
// ── Controller — no extra code needed ────────────────────────────────
@GetMapping(produces = {"application/json", "text/csv"})
public List<UserResponse> findAll() {
return userService.findAll();
// Accept: application/json → JSON
// Accept: text/csv → CSV via CsvHttpMessageConverter
}YAML Support via Custom Converter
Adding YAML as a supported response format follows the same pattern as CSV. Jackson's YAML dataformat module provides the serializer; a thin HttpMessageConverter bridges it into Spring's negotiation pipeline.
XML
<!-- pom.xml -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
// ── YAML message converter ────────────────────────────────────────────
@Component
public class YamlHttpMessageConverter
extends AbstractJackson2HttpMessageConverter {
public YamlHttpMessageConverter() {
super(new YAMLMapper().registerModule(new JavaTimeModule()),
new MediaType("application", "yaml"),
new MediaType("text", "yaml"));
}
}
// ── Register (Spring picks up @Component automatically,
// or add via WebMvcConfigurer.extendMessageConverters) ───────────────
// ── Controller ────────────────────────────────────────────────────────
@GetMapping(
value = "/{id}",
produces = {
MediaType.APPLICATION_JSON_VALUE,
"application/yaml"
}
)
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
// Accept: application/json → {"id":1,"name":"Alice"}
// Accept: application/yaml → id: 1
name: Alice
}Versioning via Media Type (Vendor MIME)
Vendor media types (application/vnd.myapp.user.v2+json) are a clean way to version a REST API without changing the URL. The client signals the desired version in the Accept header; Spring routes to the correct handler method via produces matching.
Java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// ── V1 ────────────────────────────────────────────────────────────
@GetMapping(
value = "/{id}",
produces = "application/vnd.myapp.user.v1+json"
)
public UserResponseV1 findByIdV1(@PathVariable Long id) {
return userService.findByIdV1(id);
}
// ── V2 — richer response, different field names ───────────────────
@GetMapping(
value = "/{id}",
produces = "application/vnd.myapp.user.v2+json"
)
public UserResponseV2 findByIdV2(@PathVariable Long id) {
return userService.findByIdV2(id);
}
// ── Default fallback for unversioned clients ──────────────────────
@GetMapping(
value = "/{id}",
produces = MediaType.APPLICATION_JSON_VALUE
)
public UserResponseV2 findById(@PathVariable Long id) {
return userService.findByIdV2(id); // default to latest version
}
}
// ── Example requests ──────────────────────────────────────────────────
// GET /api/users/1
// Accept: application/vnd.myapp.user.v1+json → UserResponseV1
// Accept: application/vnd.myapp.user.v2+json → UserResponseV2
// Accept: application/json → UserResponseV2 (default)Handling 406 Not Acceptable
When no registered converter can satisfy the client's Accept header, Spring throws HttpMediaTypeNotAcceptableException and returns 406. Handle it in @RestControllerAdvice to return a consistent error body instead of Spring's default empty response.
Java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public ResponseEntity<ErrorResponse> handleNotAcceptable(
HttpMediaTypeNotAcceptableException ex) {
String supported = ex.getSupportedMediaTypes().stream()
.map(MediaType::toString)
.collect(Collectors.joining(", "));
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE)
.contentType(MediaType.APPLICATION_JSON)
.body(ErrorResponse.of(
406,
"Not Acceptable",
"Supported media types: " + supported));
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleNotSupported(
HttpMediaTypeNotSupportedException ex) {
String supported = ex.getSupportedMediaTypes().stream()
.map(MediaType::toString)
.collect(Collectors.joining(", "));
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.contentType(MediaType.APPLICATION_JSON)
.body(ErrorResponse.of(
415,
"Unsupported Media Type",
"Supported media types: " + supported));
}
}