Content Negotiation with Servlet Filter in Spring (Boot)

Featured image for sharing metadata for article

Update 2022-01-29: You may be interested in using spring-content-negotiator to make this simpler!

As noted in Content Negotiation with ControllerAdvice and ExceptionHandlers in Spring (Boot), content negotiation is useful for providing different representations of formats for clients.

(I'd recommend a read of that article before this one to understand a bit more of the context for what is recommended below.)

However, you may also want to be doing this when handling errors in Filters, which doesn't work as nicely.

Note that this will only work with Spring, as this depends on Spring classes.

Let's assume that we have the following filter that performs error handling:

@Component
public class CorrelationIdFilter extends OncePerRequestFilter {

  private final ObjectMapper objectMapper;

  public CorrelationIdFilter(ObjectMapper objectMapper) {
    this.objectMapper = objectMapper;
  }

  @Override
  protected void doFilterInternal(
      HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    String uuid = request.getHeader("correlation-id");
    if (uuid == null) {
      uuid = UUID.randomUUID().toString();
    }
    if (!uuid.matches(Patterns.UUID_STRING)) {
      handleInvalidCorrelationId(response);
      return; // make sure you have this set!
    }
    try {
      filterChain.doFilter(request, response);
    } finally {
      response.addHeader("correlation-id", uuid);
    }
  }

  private void handleInvalidCorrelationId(HttpServletResponse response) throws IOException {
    ErrorResponse errorResponse = new ErrorResponse();
    errorResponse.setError(ErrorResponse.Error.INVALID_REQUEST);
    errorResponse.setErrorDescription("The correlation-id is not a valid UUID.");

    response.setContentType("application/json");
    response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
  }
}

But we know that we may receive two types of API call, one that requests application/json, and one that requires text/plain, and we need to return the appropriate error response.

We can instead rewrite the Filter to perform content-negotiation and return the right representation:

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import me.jvt.contentnegotiation.ContentTypeNegotiator;
import me.jvt.contentnegotiation.NotAcceptableException;
import me.jvt.uuid.Patterns;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.handler.DispatcherServletWebRequest;
import uk.gov.api.models.metadata.ErrorResponse;

@Component
public class CorrelationIdFilter extends OncePerRequestFilter {

  private final ObjectMapper objectMapper;
  private final ContentNegotiationManager contentNegotiationManager;

  public CorrelationIdFilter(
      ObjectMapper objectMapper, ContentNegotiationManager contentNegotiationManager) {
    this.objectMapper = objectMapper;
    this.contentNegotiationManager = contentNegotiationManager;
  }

  @Override
  protected void doFilterInternal(
      HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    String uuid = request.getHeader("correlation-id");
    if (uuid == null) {
      uuid = UUID.randomUUID().toString();
    }
    if (!uuid.matches(Patterns.UUID_STRING)) {
      handleInvalidCorrelationId(request, response);
      return; // make sure you have this set!
    }
    try {
      filterChain.doFilter(request, response);
    } finally {
      response.addHeader("correlation-id", uuid);
    }
  }

  private void handleInvalidCorrelationId(HttpServletRequest request, HttpServletResponse response)
      throws IOException {
    ContentTypeNegotiator negotiator = negotiator("application/json", "text/plain");
    MediaType resolved;
    try {
      resolved = resolve(request, negotiator);
    } catch (HttpMediaTypeNotAcceptableException ex) {
      response.setStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
      return;
    }
    if (MediaType.valueOf("application/json").isCompatibleWith(resolved)) {
      ErrorResponse errorResponse = new ErrorResponse();
      errorResponse.setError(ErrorResponse.Error.INVALID_REQUEST);
      errorResponse.setErrorDescription("The correlation-id is not a valid UUID.");

      response.setContentType(resolved.toString());
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    } else if (MediaType.valueOf("text/plain").isCompatibleWith(resolved)) {
      response.setContentType(resolved.toString());
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      response.getWriter().write("The correlation-id is not a valid UUID.");
    } else {
      throw new IllegalStateException("MediaType " + resolved + " not handled");
    }
  }

  private MediaType resolve(HttpServletRequest request, ContentTypeNegotiator negotiator)
      throws HttpMediaTypeNotAcceptableException {
    List<MediaType> mediaTypes =
        contentNegotiationManager.resolveMediaTypes(new DispatcherServletWebRequest(request));
    List<me.jvt.http.mediatype.MediaType> converted =
        mediaTypes.stream().map(me.jvt.http.mediatype.MediaType::from).collect(Collectors.toList());
    try {
      me.jvt.http.mediatype.MediaType negotiated = negotiator.negotiate(converted);
      return MediaType.valueOf(negotiated.toString());
    } catch (NotAcceptableException e) {
      throw new HttpMediaTypeNotAcceptableException(e.getMessage());
    }
  }

  private static List<me.jvt.http.mediatype.MediaType> supported(String... mediaTypes) {
    return Arrays.stream(mediaTypes)
        .map(me.jvt.http.mediatype.MediaType::valueOf)
        .collect(Collectors.toList());
  }

  private static ContentTypeNegotiator negotiator(String... mediaTypes) {
    return new ContentTypeNegotiator(supported(mediaTypes));
  }
}

Which we can see working below:

% curl localhost:8080/apis -i -H 'Correlation-ID: 12' -H "accept: text/p
lain"
HTTP/1.1 400
Content-Type: text/plain;charset=ISO-8859-1
Content-Length: 39
Date: Tue, 18 Jan 2022 11:58:34 GMT
Connection: close

The correlation-id is not a valid UUID

% curl localhost:8080/apis -i -H 'Correlation-ID: 12' -H "accept: application/json"
HTTP/1.1 400
Content-Type: application/json;charset=ISO-8859-1
Content-Length: 89
Date: Tue, 18 Jan 2022 11:59:00 GMT
Connection: close

{"error":"invalid_request","error-description":"The correlation-id is not a valid UUID."}

Written by Jamie Tanna's profile image Jamie Tanna on , and last updated on .

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under the Apache License 2.0.

#blogumentation #java #spring-boot #spring #content-negotiation.

This post was filed under articles.

Interactions with this post

Interactions with this post

Below you can find the interactions that this page has had using WebMention.

Have you written a response to this post? Let me know the URL:

Do you not have a website set up with WebMention capabilities? You can use Comment Parade.