Nugget Friday - Mastering Jakarta REST Filter Chains

Photo of Luqman Saeed by Luqman Saeed

In today's Nugget Friday, we're tackling a powerful but often misunderstood feature of Jakarta REST (formerly JAX-RS): filters and filter chains. Whether you're handling security, logging, compression or other cross-cutting concerns, understanding filters is important for building reliable Jakarta REST applications on the Jakarta EE (formerly Java EE) platform. So grab your favorite beverage, and let's dig in!

The Problem

When building enterprise REST applications, you often need to perform common operations across multiple endpoints, such as logging requests, authenticating users or compressing responses. Adding this logic directly in resource methods leads to code duplication and maintenance headaches. How can we handle these cross-cutting concerns elegantly without cluttering our resource methods?

The Solution: JAX-RS Filter 

At their core, filters enable you to execute code at well-defined points in the request/response processing chain. There are four main types of filters:

  • ClientRequestFilter - Executes before sending a client request
  • ClientResponseFilter - Executes after receiving a client response
  • ContainerRequestFilter - Executes when receiving a server request
  • ContainerResponseFilter - Executes before sending a server response

Let's explore each component and see how they fit together.

1. ClientRequestFilter

  • What it is: This filter intercepts requests before they're sent from the client to the server. It allows you to modify the outgoing request in various ways.
  • Possible uses:
    • Adding custom headers to the request, e.g. for authentication or logging
    • Modifying the request entity, e.g. encrypting the payload
    • Logging request details for debugging
@Provider
public class MyClientRequestFilter implements ClientRequestFilter {

   @Override
    public void filter(ClientRequestContext requestContext) throws IOException {

        requestContext.getHeaders().add("X-Custom-Header", "MyValue");

    }

}

2. ClientResponseFilter

  • What it is: This filter intercepts responses after they're received by the client from the server. It gives you a chance to process the incoming response.
  • Possible uses:
    • Logging response details
    • Modifying the response entity, e.g. decrypting the payload
    • Handling errors or redirects
@Provider

public class MyClientResponseFilter implements ClientResponseFilter {

   @Override
    public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) throws IOException {

       // Log the response status code
        System.out.println("Response Status Code: " + responseContext.getStatus());

   }
}

3. ContainerRequestFilter

  • What it is: This filter intercepts requests when they arrive at the server, before they reach your resource methods. Think of it as pre-processing for incoming requests.
  • Possible uses:
    • Authentication and authorization, e.g. checking API keys or JWTs
    • Input validation
    • Request logging and auditing
@Provider

public class MyContainerRequestFilter implements ContainerRequestFilter {

   @Override
    public void filter(ContainerRequestContext requestContext) throws IOException {

       // Check for an API key in the request headers
       String apiKey = requestContext.getHeaderString("X-Api-Key");
       if (apiKey == null || !apiKey.equals("your_api_key")) {
            requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());

       }
   }
}

4. ContainerResponseFilter

  • What it is: This filter intercepts responses before they're sent back from the server to the client. It allows you to modify the outgoing response.
  • Possible uses:
    • Adding custom headers to the response, e.g. CORS headers
    • Modifying the response entity
    • Logging response data
@Provider
public class MyContainerResponseFilter implements ContainerResponseFilter {

   @Override
   public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException {
        responseContext.getHeaders().add("X-Powered-By", "My Awesome API");

    }

}

Important Notes on JAX-RS Filters:

  • @Provider annotation: Make sure to use the @Provider annotation to register your filter classes with the JAX-RS runtime
  • Order of execution: If you have multiple filters of the same type, you can control their execution order using the @Priority annotation

Why You Should Care

Filter chains offer several key benefits:

  1. Separation of Concerns: Keep your resource methods focused on business logic while handling cross-cutting concerns in filters
  2. Reusability: Write once, apply anywhere - filters can be reused across different resources
  3. Flexibility: Control exactly where and when filters are applied using binding mechanisms
  4. Ordered Processing: Use priorities to ensure filters execute in the correct sequence

Advanced Features and Considerations

Pre-Matching Filters

Sometimes you need to execute a filter before URI matching occurs. The @PreMatching annotation enables this:

@Provider
@PreMatching
public class HttpMethodOverrideFilter implements ContainerRequestFilter {

   @Override
    public void filter(ContainerRequestContext requestContext) {

       if (requestContext.getMethod().equalsIgnoreCase("POST")) {
           String override = requestContext.getHeaders()
               .getFirst("X-HTTP-Method-Override");
           if (override != null) {
               requestContext.setMethod(override);
           }
       }
   }
}

Practical Filter Use Cases

Beyond security, filters can solve many common enterprise needs. Here are some practical examples:

Request/Response Timing

@Provider
public class TimingFilter implements ContainerRequestFilter,
                                   ContainerResponseFilter {

    private static final String TIMING_KEY = "request-timer";


   @Override
   public void filter(ContainerRequestContext requestContext) {
        requestContext.setProperty(TIMING_KEY, System.nanoTime());

    }




   @Override
   public void filter(ContainerRequestContext requestContext,
                      ContainerResponseContext responseContext) {

       Long startTime = (Long) requestContext.getProperty(TIMING_KEY);
        long duration = System.nanoTime() - startTime;

       responseContext.getHeaders().add(
           "X-Request-Duration", 
            String.format("%.2f ms", duration / 1_000_000.0)

       );
        

       if (duration > TimeUnit.SECONDS.toNanos(1)) {
           logger.warning(String.format(
               "Slow request to %s: %.2f ms",
               requestContext.getUriInfo().getPath(),
               duration / 1_000_000.0
           ));
        }

    }

}

Response Compression

@Provider

public class CompressionFilter implements ContainerResponseFilter {




    @Override

   public void filter(ContainerRequestContext requestContext,
                     ContainerResponseContext responseContext) {
       // Get the "Accept-Encoding" header from the request
       String acceptEncoding = requestContext.getHeaderString("Accept-Encoding");

        // Check if the client accepts gzip compression and the response should be compressed

        if (acceptEncoding != null && acceptEncoding.contains("gzip") && shouldCompress(responseContext)) {

            // Get the response entity

            Object entity = responseContext.getEntity();

            // Set the response entity with gzip content encoding

           responseContext.setEntity(entity,
                   responseContext.getEntityAnnotations(),
                   new Variant(responseContext.getMediaType(),
                           responseContext.getLanguage(),
                            new ContentEncoding("gzip")));

       }
   }
}

Request Tracing

@Provider
@Priority(Priorities.HEADER_DECORATOR)
public class CorrelationFilter implements ContainerRequestFilter,
                                       ContainerResponseFilter {
    private static final String CORRELATION_ID_HEADER = "X-Correlation-ID";


   @Override
    public void filter(ContainerRequestContext requestContext) {

       String correlationId = requestContext.getHeaderString(CORRELATION_ID_HEADER);
       if (correlationId == null) {
            correlationId = UUID.randomUUID().toString();

        }

       
       MDC.put("correlationId", correlationId);
       requestContext.setProperty("correlationId", correlationId);
    }

    

   @Override
   public void filter(ContainerRequestContext requestContext,
                      ContainerResponseContext responseContext) {

       String correlationId = 
            (String) requestContext.getProperty("correlationId");

       responseContext.getHeaders()
            .add(CORRELATION_ID_HEADER, correlationId);

        MDC.remove("correlationId");

    }

}

Selective Filter Application Using Name Binding

Sometimes applying a filter globally doesn't make sense. For example, you might want detailed request logging only for certain sensitive operations, not for every endpoint in your application. This is where name binding comes in: it provides a way to selectively apply filters to specific resource methods or classes.

Let's break down how name binding works with a practical example:

1. First, create a custom binding annotation:
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Logged { }

Here's what each part means:

  • @NameBinding tells Jakarta REST this is a binding annotation
  • @Retention(RetentionPolicy.RUNTIME) ensures the annotation is available during execution
  • @Target({ElementType.TYPE, ElementType.METHOD}) allows the annotation on both classes and methods
2. Create your filter and mark it with the binding annotation:
@Provider

@Logged

public class DetailedLoggingFilter implements ContainerRequestFilter {

    @Override

    public void filter(ContainerRequestContext requestContext) {

        String method = requestContext.getMethod();

        String path = requestContext.getUriInfo().getPath();

        String body = extractBody(requestContext);

        

        logger.info("Detailed request log:");

        logger.info("Method: {}", method);

        logger.info("Path: {}", path);

        logger.info("Headers: {}", requestContext.getHeaders());

        logger.info("Body: {}", body);

        logger.info("Query params: {}", requestContext.getUriInfo()

                                                    .getQueryParameters());

    }

    

    private String extractBody(ContainerRequestContext context) {

        // Body extraction logic

    }

}@Path("/sensitive")
3. Apply the annotation to specific resource methods or classes:
public class SensitiveResource {

   @GET
   @Path("/user-data")
   @Logged  // This method will use the detailed logging
   public Response getSensitiveData() {
        return Response.ok(fetchSensitiveData()).build();

    }

   @GET
    @Path("/public-data")

   // No @Logged annotation - filter won't apply here
   public Response getPublicData() {
        return Response.ok(fetchPublicData()).build();

    }

}

You can also apply the binding at the class level to have it affect all methods:

@Path("/sensitive")
@Logged  // All methods in this class will use the detailed logging
public class VerySensitiveResource {

   @GET
   @Path("/data1")
    public Response getData1() { ... }


   @GET
   @Path("/data2")
    public Response getData2() { ... }

}

The filter will only execute for methods or classes annotated with @Logged. This gives you fine-grained control over where the filter applies.

You can even combine multiple binding annotations for more complex scenarios:

@NameBinding
public @interface Authenticated { }

@NameBinding
public @interface Logged { }

@Path("/data")
public class DataResource {

   @GET
   @Authenticated
   @Logged  // Both authentication and logging filters will apply
    public Response getProtectedData() { ... }

}


This approach helps you:

  • Reduce overhead by only applying filters where needed
  • Create clear documentation about which endpoints have special handling
  • Keep resource-intensive operations, like detailed logging, focused on critical paths
  • Maintain cleaner code by making filter application explicit

Remember that if you need even more dynamic control over filter application, you can use dynamic binding through the DynamicFeature interface instead of name binding.

That's the power of selective filter application - you get precise control over where and when your filters execute, leading to better performance and clearer code organization.

Caveats

  1. Filter Order Matters: Be careful with filter ordering. Use @Priority to control the sequence
  2. Performance Impact: Each filter adds processing overhead. Use them judiciously
  3. Thread Safety: Filters must be thread-safe as they can handle multiple requests simultaneously
  4. Exception Handling: Properly handle exceptions in filters to avoid request processing failures
  5. Context Sharing: Use request properties to share data between filters in the chain

Conclusions

JAX-RS filters are a powerful tool in the Jakarta REST toolkit. They provide a clean, maintainable way to handle cross-cutting concerns in your REST applications. Whether you're implementing security, logging, compression or any other common functionality, filters help keep your code organized and maintainable.

Key takeaways:

  • Use filters for cross-cutting concerns
  • Use priorities to control execution order
  • Apply filters selectively using name binding
  • Consider performance implications
  • Handle exceptions appropriately

That's it for this week's deep dive! Download Payara Server Community for free and start building more maintainable Jakarta REST services with filters. And stay tuned for more Jakarta EE and Java nuggets. Happy coding!

 

Related Posts

Comments