Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there a way to automatically propagate an incoming HTTP header in a JAX-RS request to an outgoing JAX-RS request?

Tags:

jersey

jax-rs

I'm looking for the proper way—in a Jersey application—to read a header from an incoming request and automatically install it in any outgoing requests that might be made by a JAX-RS client that my application is using.

Ideally I'd like to do this without polluting any of my classes' inner logic at all, so via various filters and interceptors.

For simple use cases, I can do this: I have a ClientRequestFilter implementation that I register on my ClientBuilder, and that filter implementation has:

@Context
private HttpHeaders headers;

...which is a context-sensitive proxy (by definition), so in its filter method it can refer to headers that were present on the inbound request that's driving all this, and install them on the outgoing request. For straightforward cases, this appears to work OK.

However, this fails in the case of asynchronicity: if I use the JAX-RS asynchronous client APIs to spawn a bunch of GETs, the filter is still invoked, but can no longer invoke methods on that headers instance variable; Jersey complains that as far as it knows we're no longer in request scope. This makes sense if request scope is defined to be per-thread: the spawned GETs are running in some Jersey-managed thread pool somewhere, not on the same thread as the one with which the headers proxy is associated, so that proxy throws IllegalStateExceptions all over the place when my filter tries to talk to it.

I feel like there's some combination of ContainerRequestFilter and ClientRequestFilter that should be able to get the job done even in asynchronous cases, but I'm not seeing it.

like image 270
Laird Nelson Avatar asked Nov 29 '17 23:11

Laird Nelson


1 Answers

What I would do is make a WebTarget injectable that is preconfigured with a ClientRequestFilter to add the headers. It's better to configure the WebTarget this way, as opposed to the Client, since the Client is an expensive object to create.

We can make the WebTarget injectable using a custom annotation and an InjectionResolver. In the InjectionResolver, we can get the ContainerRequest and get the headers from that, which we will pass to the ClientRequestFilter.

Here it is in action

Create the custom annotation

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface WithHeadersTarget {
    String baseUri();
    String[] headerNames() default {};
}

Make the InjectionResolver with the custom ClientRequestFilter

private static class WithHeadersTargetInjectionResolver
        implements InjectionResolver<WithHeadersTarget> {

    private final Provider<ContainerRequest> requestProvider;
    private final Client client;

    @Inject
    public WithHeadersTargetInjectionResolver(Provider<ContainerRequest> requestProvider) {
        this.requestProvider = requestProvider;
        this.client = ClientBuilder.newClient();
    }

    @Override
    public Object resolve(Injectee injectee, ServiceHandle<?> handle) {
        if (injectee.getRequiredType() == WebTarget.class
                && injectee.getParent().isAnnotationPresent(WithHeadersTarget.class)) {
            WithHeadersTarget anno = injectee.getParent().getAnnotation(WithHeadersTarget.class);
            String uri = anno.baseUri();
            String[] headersNames = anno.headerNames();
            MultivaluedMap<String, String> requestHeaders = requestProvider.get().getRequestHeaders();

            return client.target(uri)
                    .register(new HeadersFilter(requestHeaders, headersNames));
        }
        return null;
    }

    @Override
    public boolean isConstructorParameterIndicator() {
        return false;
    }

    @Override
    public boolean isMethodParameterIndicator() {
        return false;
    }

    private class HeadersFilter implements ClientRequestFilter {

        private final MultivaluedMap<String, String> headers;
        private final String[] headerNames;

        private HeadersFilter(MultivaluedMap<String, String> headers, String[] headerNames) {
            this.headers = headers;
            this.headerNames = headerNames;
        }

        @Override
        public void filter(ClientRequestContext requestContext) throws IOException {
            // if headers names is empty, add all headers
            if (this.headerNames.length == 0) {
                for (Map.Entry<String, List<String>> entry: this.headers.entrySet()) {
                    requestContext.getHeaders().put(entry.getKey(), new ArrayList<>(entry.getValue()));
                }
            // else just add the headers from the annotation
            } else {
                for (String header: this.headerNames) {
                    requestContext.getHeaders().put(header, new ArrayList<>(this.headers.get(header)));
                }
            }
        }
    }
}

One thing about this implementation is that it checks for an empty headerNames in the @WithHeadersTarget annotation. If it is empty, then we just forward all headers. If the user specifies some header names, then it will only forward those

Register the InjectionResolver

new ResourceConfig()
   .register(new AbstractBinder() {
        @Override
        protected void configure() {
            bind(WithHeadersTargetInjectionResolver.class)
                  .to(new TypeLiteral<InjectionResolver<WithHeadersTarget>>() {
                   }).in(Singleton.class);
            }
        })

Use it

@Path("test")
public static class TestResource {

    @WithHeadersTarget(
            baseUri = BASE_URI
            headerNames = {TEST_HEADER_NAME})
    private WebTarget target;

    @GET
    public String get() {
        return target.path("client").request().get(String.class);
    }
}

In this example if, the headerNames is left out, then it will default to an empty array, which will cause all the request headers to be forwarded.

Complete test using Jersey Test Framework

import org.glassfish.hk2.api.Injectee;
import org.glassfish.hk2.api.InjectionResolver;
import org.glassfish.hk2.api.ServiceHandle;
import org.glassfish.hk2.api.TypeLiteral;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.filter.LoggingFilter;
import org.glassfish.jersey.server.ContainerRequest;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.test.JerseyTest;
import org.junit.Test;

import javax.inject.Inject;
import javax.inject.Provider;
import javax.inject.Singleton;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.Path;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.ClientRequestContext;
import javax.ws.rs.client.ClientRequestFilter;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import java.io.IOException;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

import static org.assertj.core.api.Assertions.assertThat;

public class ForwardHeadersTest extends JerseyTest {

    private static final String BASE_URI = "http://localhost:8000";
    private static final String TEST_HEADER_NAME = "X-Test-Header";

    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface WithHeadersTarget {
        String baseUri();
        String[] headerNames() default {};
    }

    @Path("test")
    public static class TestResource {

        @WithHeadersTarget(
                baseUri = BASE_URI
                )
        private WebTarget target;

        @GET
        public String get() {
            return target.path("client").request().get(String.class);
        }
    }

    @Path("client")
    public static class ClientResource {

        @GET
        public String getReversedHeader(@HeaderParam(TEST_HEADER_NAME) String header) {
            System.out.println(header);
            return new StringBuilder(header).reverse().toString();
        }
    }

    private static class WithHeadersTargetInjectionResolver
            implements InjectionResolver<WithHeadersTarget> {

        private final Provider<ContainerRequest> requestProvider;
        private final Client client;

        @Inject
        public WithHeadersTargetInjectionResolver(Provider<ContainerRequest> requestProvider) {
            this.requestProvider = requestProvider;
            this.client = ClientBuilder.newClient();
        }

        @Override
        public Object resolve(Injectee injectee, ServiceHandle<?> handle) {
            if (injectee.getRequiredType() == WebTarget.class
                    && injectee.getParent().isAnnotationPresent(WithHeadersTarget.class)) {
                WithHeadersTarget anno = injectee.getParent().getAnnotation(WithHeadersTarget.class);
                String uri = anno.baseUri();
                String[] headersNames = anno.headerNames();
                MultivaluedMap<String, String> requestHeaders = requestProvider.get().getRequestHeaders();

                return client.target(uri)
                        .register(new HeadersFilter(requestHeaders, headersNames));
            }
            return null;
        }

        @Override
        public boolean isConstructorParameterIndicator() {
            return false;
        }

        @Override
        public boolean isMethodParameterIndicator() {
            return false;
        }

        private class HeadersFilter implements ClientRequestFilter {

            private final MultivaluedMap<String, String> headers;
            private final String[] headerNames;

            private HeadersFilter(MultivaluedMap<String, String> headers, String[] headerNames) {
                this.headers = headers;
                this.headerNames = headerNames;
            }

            @Override
            public void filter(ClientRequestContext requestContext) throws IOException {
                // if headers names is empty, add all headers
                if (this.headerNames.length == 0) {
                    for (Map.Entry<String, List<String>> entry: this.headers.entrySet()) {
                        requestContext.getHeaders().put(entry.getKey(), new ArrayList<>(entry.getValue()));
                    }
                // else just add the headers from the annotation
                } else {
                    for (String header: this.headerNames) {
                        requestContext.getHeaders().put(header, new ArrayList<>(this.headers.get(header)));
                    }
                }
            }
        }
    }

    @Override
    public ResourceConfig configure() {
        return new ResourceConfig()
                .register(TestResource.class)
                .register(ClientResource.class)
                .register(new AbstractBinder() {
                    @Override
                    protected void configure() {
                        bind(WithHeadersTargetInjectionResolver.class)
                                .to(new TypeLiteral<InjectionResolver<WithHeadersTarget>>() {
                                }).in(Singleton.class);
                    }
                })
                .register(new LoggingFilter(Logger.getAnonymousLogger(), true))
                .register(new ExceptionMapper<Throwable>() {
                    @Override
                    public Response toResponse(Throwable t) {
                        t.printStackTrace();
                        return Response.serverError().entity(t.getMessage()).build();
                    }
                });
    }

    @Override
    public URI getBaseUri() {
        return URI.create(BASE_URI);
    }

    @Test
    public void testIt() {
        final String response = target("test")
                .request()
                .header(TEST_HEADER_NAME, "HelloWorld")
                .get(String.class);

        assertThat(response).isEqualTo("dlroWolleH");
    }
}
like image 118
Paul Samsotha Avatar answered Oct 19 '22 12:10

Paul Samsotha