Request Headers and Content Negotiation

Read headers with @RequestHeader, serve JSON or CSV from one URL based on Accept, and use ETags for caching and safe concurrent updates.

Syntax@GetMapping(path = "/api/orders", produces = "text/csv") String csv(@RequestHeader(name = "X-Request-Id", required = false) String requestId) { ... } ResponseEntity.ok().eTag("\"7\"").body(order)

Headers carry metadata about a request: who is calling, what formats they accept, what version of a resource they last saw. @RequestHeader("X-Request-Id") String requestId binds one header; like @RequestParam it is required unless you give required = false, a defaultValue or an Optional. Header names are case-insensitive. To see them all, declare @RequestHeader HttpHeaders headers. The annotation matters: a bare HttpHeaders parameter is treated as a model attribute and arrives empty.

Do not read Authorization by hand in controllers. Authentication belongs in Spring Security's filter chain, where it runs once for every endpoint, before any controller sees the request.

Content negotiation

The client says what it can read with Accept and what it is sending with Content-Type. Spring MVC matches Accept against the produces of each mapping and against the message converters that can write the return value. If nothing fits, the client gets 406 Not Acceptable. Two methods on the same path with different produces let one URL serve JSON to the app and CSV to a spreadsheet.

@GetMapping(produces = "application/json") List<Order> json()
@GetMapping(produces = "text/csv")         String csv()

Browsers and some tools cannot set Accept easily, so Spring Boot can also pick the format from a query parameter. Turn it on with spring.mvc.contentnegotiation.favor-parameter=true, after which ?format=csv works for media types you register. Path extensions such as /orders.csv are no longer supported for negotiation; use the parameter.

When the response depends on a request header, say so with Vary, or a shared cache may hand CSV to a client that asked for JSON.

ETags

An ETag is a short fingerprint of a resource's current state, often its version number. Return it in the ETag header and two things become possible:

  • Cheap re-reads. The client sends If-None-Match with the tag it has. When a GET handler returns a ResponseEntity with an ETag, Spring compares them and sends 304 Not Modified with no body when they match.
  • Safe updates. The client sends If-Match on PUT. If the tag no longer matches, someone else changed the resource; answer 412 Precondition Failed instead of overwriting their work. Answer 428 Precondition Required when the header is missing and you insist on it.

The 304 saves bandwidth, not database work, because your handler still loads the row to compute the tag. For expensive resources, keep the version somewhere cheap to read.

Example

Example · java
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

record OrderRow(long id, String customer, BigDecimal total) {}

@RestController
@RequestMapping("/api/reports/orders")
public class OrderReportController {

    private final List<OrderRow> rows = List.of(
            new OrderRow(1, "[email protected]", new BigDecimal("42.50")),
            new OrderRow(2, "[email protected]", new BigDecimal("9.99")));

    // Accept: application/json
    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<List<OrderRow>> json(
            @RequestHeader(name = "X-Request-Id", required = false) String requestId) {
        return ResponseEntity.ok()
                .varyBy(HttpHeaders.ACCEPT)
                .header("X-Request-Id", requestId != null ? requestId : "none")
                .body(rows);
    }

    // Accept: text/csv
    @GetMapping(produces = "text/csv")
    public ResponseEntity<String> csv() {
        String body = rows.stream()
                .map(r -> r.id() + "," + r.customer() + "," + r.total())
                .collect(Collectors.joining("\n", "id,customer,total\n", "\n"));
        return ResponseEntity.ok()
                .varyBy(HttpHeaders.ACCEPT)
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"orders.csv\"")
                .body(body);
    }
}

When to use it

  • Serve an orders report as JSON to the dashboard and as a CSV download to finance from the same URL.
  • Let a mobile app revalidate cached articles with If-None-Match and receive 304 instead of the full body.
  • Stop two admins from overwriting each other's edits to a CMS page by requiring If-Match on PUT.

More examples

ETag and 304 Not Modified

The first GET returns <code>ETag: "7"</code>. The client stores it and sends <code>If-None-Match: "7"</code> next time; while the version is unchanged, Spring turns the response into a 304 automatically, and the client reuses its copy. Using a version column as the tag is cheaper and more reliable than hashing the JSON.

Example · java
import java.util.Map;
import java.util.Optional;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

record Article(long id, long version, String title, String body) {}

@RestController
public class ArticleReadController {

    private final Map<Long, Article> articles = Map.of(
            1L, new Article(1, 7, "Caching with ETags", "..."));

    @GetMapping("/api/articles/{id}")
    public ResponseEntity<Article> get(@PathVariable long id) {
        return Optional.ofNullable(articles.get(id))
                // Spring compares this with If-None-Match and sends 304 with no body on a match.
                .map(a -> ResponseEntity.ok().eTag("\"" + a.version() + "\"").body(a))
                .orElseGet(() -> ResponseEntity.notFound().build());
    }
}

If-Match for safe updates

Two editors open version 3. The first saves and the page becomes version 4. The second sends <code>If-Match: "3"</code> and gets 412, so the client can reload and merge rather than silently overwrite. In a database-backed app the check and the write must be atomic; JPA's <code>@Version</code> gives you that, and the transactions category shows how.

Example · java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RestController;

record Page(long id, long version, String title) {}

record PageUpdate(String title) {}

@RestController
public class PageController {

    private final Map<Long, Page> pages = new ConcurrentHashMap<>(Map.of(1L, new Page(1, 3, "Home")));

    @PutMapping("/api/pages/{id}")
    public ResponseEntity<Page> update(@PathVariable long id,
                                       @RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
                                       @RequestBody PageUpdate update) {
        if (ifMatch == null) {
            return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED).build();      // 428
        }
        Page current = pages.get(id);
        if (current == null) {
            return ResponseEntity.notFound().build();
        }
        if (!ifMatch.equals("\"" + current.version() + "\"")) {
            return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();        // 412
        }
        Page saved = new Page(id, current.version() + 1, update.title());
        pages.put(id, saved);
        return ResponseEntity.ok().eTag("\"" + saved.version() + "\"").body(saved);
    }
}

Choosing the format with ?format=

<code>format</code> is already the default parameter name; setting it makes the choice visible to the next reader. Each <code>media-types</code> entry maps a parameter value to a media type, which is then matched against <code>produces</code> exactly as an <code>Accept</code> header would be. This is handy for download links in a browser, where you cannot set headers on a plain <code>&lt;a href&gt;</code>.

Example · properties
# GET /api/reports/orders?format=csv behaves like Accept: text/csv
spring.mvc.contentnegotiation.favor-parameter=true
spring.mvc.contentnegotiation.parameter-name=format
spring.mvc.contentnegotiation.media-types.csv=text/csv
spring.mvc.contentnegotiation.media-types.json=application/json

Discussion

  • Be the first to comment on this lesson.