Search code examples
micronautmicronaut-client

How do I route a request using a discriminator and declarative client?


Use case

I'm trying to call a service, using a micronaut declarative client. The service is actually many services all the same, but hosted on a different host for each tenet in our system. e.g.

  • tenetA.example.com/api
  • tenetB.example.com/api

From micronaut, I would like to use a request header X-tenetID, and make calls to the correct service based on it. Sounds simple enough right?

1st attempt: Using a filter

The first thing I tried was using a filter on the client

@FilterMatcher
@Documented
@Retention(RUNTIME)
@Target({TYPE, PARAMETER})
public @interface MyFilterAnnotation
{
}

@MyFilterAnnotation
@Client("http://replaceme.example.com/")
public interface MyClient
{
    @Post(uri = "some/endpoint")
    AuthResponse auth(@Body CustomCredentials credentials, @RequestAttribute(name = "tenet-id") String tenetID);
}

@MyFilterAnnotation
@Singleton
public class MyFilter implements HttpClientFilter
{
    @Override
    public int getOrder()
    {
        return -10; // Tried playing with the order here to no avail
    }

    @Override
    public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request, ClientFilterChain chain)
    {
        String tenetID = request.getAttribute("tenet-id", String.class).orElse(null);
        UriBuilder builder = UriBuilder.of(request.getUri());
        builder.host(tenetID + ".example.com");
        request.uri(builder.build());
        return chain.proceed(request);
    }
}

I've confirmed my filter IS getting called, and the request uri IS getting set, but the request uri is not being honored further down the chain. Overriding the order doesn't seem to have any effect either. It's still sending traffic to replaceme.example.com. Am I doing something wrong here?

I found some forum posts that said this approach might not work, because the host that gets chosen is done early in the process to account for the LoadBalancer mechanism. Which takes me to my second attempt:

2nd attempt: Using client side LoadBalancer

Since it seems like the host of the url can't be changed in a filter, I tried writing my own DiscoveryClientLoadBalancerFactory

@Replaces(DiscoveryClientLoadBalancerFactory.class)
public class MyLoadBalancer extends DiscoveryClientLoadBalancerFactory
{

    /**
     * @param discoveryClient The discover client
     */
    public MyLoadBalancer(final DiscoveryClient discoveryClient)
    {
        super(discoveryClient);
    }


    @Override
    public LoadBalancer create(final String serviceID)
    {
        return discriminator ->
        {
            // discriminator always seems to be null here.
            return Publishers.just(ServiceInstance.of("myService", discriminator + "example.com",8080));
        };       
    }
}

I'm stuck here though, because I don't know how to tell the declarative client to use my tenetID as the discriminator. I've only ever seen the @Inject annotation create a DefaultHttpClient, which only ever calls loadBalancer.select(getLoadBalancerDiscriminator()) and getLoadBalancerDiscriminator() always returns null. Is there a way I can get the discriminator to be set based off a request header?

What isn't viable: application configuration

Some example documentation that uses bitbucket suggests that the url comes from configuration. We have tenets come and go, and each service is going to have to be authenticated with their own credentials that are stored in a cloud vault. So storing some kind of map in application.conf or similar isn't very useful here because we don't want to have to restart everytime we add a new tenet or rotate keys.

Help

Between the two approaches, the LoadBalancer route seems hackier because what I'm doing is request routing or url rewriting, and not load balancing. Should one of these approaches work? Is there a better way of doing what I want?


Solution

  • My final solution was to extend the DefaultHttpClient even though it's marked @Internal and override the resolveRequestURI method

    public interface MyClient
    {
        @Post(uri = "some/endpoint")
        AuthResponse auth(@Body CustomCredentials credentials, @Header("tenet-id") String tenetID);
    }
    
    @Singleton
    public class MyHttpClient extends DefaultHttpClient
    {
        // Takes the tenet-id header and looks up the url to send the request to.
        @Override
        protected <I> Publisher<URI> resolveRequestURI(final HttpRequest<I> request, final boolean includeContextPath)
        {
            var headers = (MutableHttpHeaders) request.getHeaders();
            var customerID = headers.get("tenet-id", String.class).orElseThrow(() -> new IllegalArgumentException("Request must have an tenet-id header"));
            var requestURI = request.getUri();
            var resolvedURI = secret.getUrl().resolve(includeContextPath ? prependContextPath(requestURI) : requestURI);
            return Publishers.just(resolvedURI);
        }
    }
    
    

    EDIT

    I'm learning that this solution is not ideal, as it provides a concrete bean candidate and prevents any kind of configuration like you would do on a normal http service. Things like tenant propagation no longer work. I'm having to work around that.