The labs on this site need JavaScript to run. The same content is written out below.
Learn › Spring › Web MVC
REST APIs and message converters
Where does the object returned by a controller become JSON?
A controller method receives Java objects and returns Java objects, but what travels over the network is text such as JSON. In between, message converters turn text into objects and objects into text. The Content-Type and Accept headers of the request decide which converter is used, and when no converter fits, the result is 415 or 406.
Checked against real execution · Checked by sending requests for 16 combinations to a Spring Boot 4.1.1 server
Concepts in this lab
- HttpMessageConverter
- This component converts an HTTP request body to a Java object, and a Java object to an HTTP response body. @RequestBody and @ResponseBody use it. For JSON, a converter that uses the Jackson library does the conversion.
- @Controller and @RestController
- A string returned by a method of an @Controller is interpreted as a view name by default. @RestController is @Controller plus @ResponseBody: the object returned by a method is converted to JSON or another format and written directly to the response body.
- Content negotiation (Accept and Content-Type)
- This is the process in which the client and the server agree, through headers, on the format in which the response body is sent. The client states the response formats it wants with the Accept header, and the server picks one of them that it can produce and writes it in the Content-Type header of the response. When a request has a body, the client states the format of that body in the Content-Type header of the request, but this is not negotiation. The sending side simply tells the other side. The values are written as media types such as application/json or text/html. When the server cannot read the format of the request body, it responds with 415 (Unsupported Media Type), and when it cannot produce a response in the format the client wants, it responds with 406 (Not Acceptable). Spring does not guess the format from the content of the body and follows the Content-Type header, so if you send JSON but write a different Content-Type, Spring cannot read it.
- ResponseEntity
- This is an object returned by a controller that holds the status code, the headers, and the body of a response all together. When you return only an object, the status code is set to 200, but when you write ResponseEntity.status(201).body(object) or ResponseEntity.created(location).body(object), the controller can set the status code and the headers itself. A message converter converts the object held as the body to JSON or another format, exactly as when the object is returned as it is. It is mostly used in methods that have to return different status codes depending on the case, and in @ExceptionHandler methods.
- ViewResolver
- This component turns the view name (a string) returned by a controller into the view object that actually renders the page. It is used when the server builds HTML and sends it as the response, and it is not used in a REST API that responds with JSON directly.
Try these
- Default settings — The request asks for the response as XML. It is rejected with 406, but the controller has already run by then.
- Valid JSON request — The JSON becomes an object, and the returned object becomes JSON again.
- Setting 201 and a header with ResponseEntity — The controller sets the status code and the Location header itself.
- When an @Controller returns a string — It is interpreted as a view name, not as the body.
- When an @RestController returns a string — The same string becomes the body as it is. It is not converted to JSON.
- With the wrong Content-Type — The request is rejected with 415 even though the content of the body is JSON.
- With malformed JSON — The converter fails while reading it, and the result is 400.
Running with the default settings
- The request arrives: POST /orders. The Content-Type header (application/json) tells "the format of the body I am sending", and the Accept header (application/xml) tells "the format of the response I want to receive".
- @RequestBody OrderRequest req: Before the controller is called, the request body has to be converted into an OrderRequest object. Spring chooses the converter that reads the body by looking at the Content-Type header. It is application/json, so the JSON message converter (Jackson) takes the body.
- Reading the JSON: Jackson finds the values with the same names in the JSON of the body and creates an OrderRequest object (item is "book", qty is 2). The text has become a Java object.
- create(req) runs: The controller method creates the order and returns an OrderResponse object.
- @RestController: This annotation is @Controller plus @ResponseBody. With @ResponseBody, the returned value is not treated as a view name, and a message converter writes it directly to the response body.
- Choosing the response format: The client asks for application/xml with Accept. This server has no converter that knows how to turn an OrderResponse object into XML. Spring responds with 406, which means "the response cannot be given in the format you want" (HttpMediaTypeNotAcceptableException). The controller method has already run, so the order has been created.
Result of this run
- 406 means "the response cannot be given in the format you want (Accept)". It is decided in the step that writes the response, so the response is a failure but the order has already been created. If you write produces = "application/json" on @PostMapping, a request whose Accept does not match is filtered out in the step that chooses the controller method, and the controller does not run.
Code
// Request sent
// POST /orders
// Content-Type: application/json
// Accept: application/xml
//
// {"item": "book", "qty": 2}
record OrderRequest(String item, int qty) { }
record OrderResponse(long id, String item, int qty) { }
@RestController // @Controller + @ResponseBody
class OrderController {
@PostMapping("/orders")
OrderResponse create(@RequestBody OrderRequest req) {
return new OrderResponse(1, req.item(), req.qty());
}
}
Assumptions of this model
All 16 combinations of the 4 kinds of controller and the 4 requests were sent as HTTP requests to a real Spring Boot 4.1.1 server (Spring Framework 7.0.9, embedded Tomcat 11, Jackson 3), and the status code, whether the controller ran, the name of the exception Spring threw, the converter that read the request body and the converter that wrote the response body, and the Content-Type and Location header of the successful responses were confirmed to match the screen. The body and the Content-Type of the error responses (400, 406, 415) vary with the version and the configuration, so they were not treated as results. The server used for the check has no library that produces XML. If you add a library such as jackson-dataformat-xml, the server responds to an Accept: application/xml request with XML, so the result is not 406. The class name of the converter (JacksonJsonHttpMessageConverter) is as of Spring 7, and the name is different in earlier versions. For the Content-Type of the response, only the format name is shown (in reality, a charset is sometimes appended). The view name returned by the @Controller was checked by registering a view object of the same name instead of using a template engine. That a request that returns a view gave 200 regardless of Accept is what was observed in this setup, and it can differ depending on the view configuration. Cases that are not on the screen were also checked on the same server. When no view with that name existed, the result was 404 (with a template engine, it can be a different error, and that was not verified), putting @ResponseBody on the method of the @Controller gave the same result as @RestController, and a method with produces = "application/json" responded to an Accept: application/xml request with 406 without running the controller. When the request was sent without an Accept header, an object went out as JSON and a string as text/plain. In the combination that returned a ResponseEntity and gave 406, the Location header remained in the response, but this can differ between versions, so it is not shown on the screen.
Previous lab: Validation · Next lab: Spring Boot auto-configuration