7.4 HTTP Client Filters

Often, you need to include the same HTTP headers or URL parameters in a set of requests against a third-party API or when calling another Microservice.

To simplify this, you can define HttpClientFilter classes that are applied to all matching HTTP client requests.

As an example, say you want to build a client to communicate with the Bintray REST API. It would be tedious to specify authentication for every single HTTP call.

To resolve this you can define a filter. The following is an example BintrayService:

  1. class BintrayApi {
  2. public static final String URL = 'https://api.bintray.com'
  3. }
  4. @Singleton
  5. class BintrayService {
  6. final HttpClient client;
  7. final String org;
  8. BintrayService(
  9. @Client(BintrayApi.URL) HttpClient client, (1)
  10. @Value("${bintray.organization}") String org ) {
  11. this.client = client;
  12. this.org = org;
  13. }
  14. Flux<HttpResponse<String>> fetchRepositories() {
  15. return Flux.from(client.exchange(HttpRequest.GET(
  16. "/repos/" + org), String.class)); (2)
  17. }
  18. Flux<HttpResponse<String>> fetchPackages(String repo) {
  19. return Flux.from(client.exchange(HttpRequest.GET(
  20. "/repos/" + org + "/" + repo + "/packages"), String.class)); (2)
  21. }
  22. }
  1. class BintrayApi {
  2. public static final String URL = 'https://api.bintray.com'
  3. }
  4. @Singleton
  5. class BintrayService {
  6. final HttpClient client
  7. final String org
  8. BintrayService(
  9. @Client(BintrayApi.URL) HttpClient client, (1)
  10. @Value('${bintray.organization}') String org ) {
  11. this.client = client
  12. this.org = org
  13. }
  14. Flux<HttpResponse<String>> fetchRepositories() {
  15. client.exchange(HttpRequest.GET("/repos/$org"), String) (2)
  16. }
  17. Flux<HttpResponse<String>> fetchPackages(String repo) {
  18. client.exchange(HttpRequest.GET("/repos/${org}/${repo}/packages"), String) (2)
  19. }
  20. }
  1. class BintrayApi {
  2. public static final String URL = 'https://api.bintray.com'
  3. }
  4. @Singleton
  5. internal class BintrayService(
  6. @param:Client(BintrayApi.URL) val client: HttpClient, (1)
  7. @param:Value("\${bintray.organization}") val org: String) {
  8. fun fetchRepositories(): Flux<HttpResponse<String>> {
  9. return Flux.from(client.exchange(HttpRequest.GET<Any>("/repos/$org"), String::class.java)) (2)
  10. }
  11. fun fetchPackages(repo: String): Flux<HttpResponse<String>> {
  12. return Flux.from(client.exchange(HttpRequest.GET<Any>("/repos/$org/$repo/packages"), String::class.java)) (2)
  13. }
  14. }
1An ReactorHttpClient is injected for the Bintray API
2The organization is configurable via configuration

The Bintray API is secured. To authenticate you add an Authorization header for every request. You can modify fetchRepositories and fetchPackages methods to include the necessary HTTP Header for each request, but using a filter is much simpler:

  1. @Filter("/repos/**") (1)
  2. class BintrayFilter implements HttpClientFilter {
  3. final String username;
  4. final String token;
  5. BintrayFilter(
  6. @Value("${bintray.username}") String username, (2)
  7. @Value("${bintray.token}") String token ) { (2)
  8. this.username = username;
  9. this.token = token;
  10. }
  11. @Override
  12. public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
  13. ClientFilterChain chain) {
  14. return chain.proceed(
  15. request.basicAuth(username, token) (3)
  16. );
  17. }
  18. }
  1. @Filter('/repos/**') (1)
  2. class BintrayFilter implements HttpClientFilter {
  3. final String username
  4. final String token
  5. BintrayFilter(
  6. @Value('${bintray.username}') String username, (2)
  7. @Value('${bintray.token}') String token ) { (2)
  8. this.username = username
  9. this.token = token
  10. }
  11. @Override
  12. Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
  13. ClientFilterChain chain) {
  14. chain.proceed(
  15. request.basicAuth(username, token) (3)
  16. )
  17. }
  18. }
  1. @Filter("/repos/**") (1)
  2. internal class BintrayFilter(
  3. @param:Value("\${bintray.username}") val username: String, (2)
  4. @param:Value("\${bintray.token}") val token: String)(2)
  5. : HttpClientFilter {
  6. override fun doFilter(request: MutableHttpRequest<*>, chain: ClientFilterChain): Publisher<out HttpResponse<*>> {
  7. return chain.proceed(
  8. request.basicAuth(username, token) (3)
  9. )
  10. }
  11. }
1You can match only a subset of paths with a Client filter.
2The username and token are injected via configuration
3The basicAuth method includes HTTP Basic credentials

Now when you invoke the bintrayService.fetchRepositories() method, the Authorization HTTP header is included in the request.

Injecting Another Client into a HttpClientFilter

To create an ReactorHttpClient, Micronaut needs to resolve all HttpClientFilter instances, which creates a circular dependency when injecting another https://micronaut-projects.github.io/micronaut-reactor/latest/api/io/micronaut/reactor/client.ReactorHttpClient or a @Client bean into an instance of a HttpClientFilter.

To resolve this issue, use the BeanProvider interface to inject another ReactorHttpClient or a @Client bean into an instance of HttpClientFilter.

The following example which implements a filter allowing authentication between services on Google Cloud Run demonstrates how to use BeanProvider to inject another client:

  1. import io.micronaut.context.BeanProvider;
  2. import io.micronaut.context.annotation.Requires;
  3. import io.micronaut.context.env.Environment;
  4. import io.micronaut.http.HttpRequest;
  5. import io.micronaut.http.HttpResponse;
  6. import io.micronaut.http.MutableHttpRequest;
  7. import io.micronaut.http.annotation.Filter;
  8. import io.micronaut.http.client.HttpClient;
  9. import io.micronaut.http.filter.ClientFilterChain;
  10. import io.micronaut.http.filter.HttpClientFilter;
  11. import org.reactivestreams.Publisher;
  12. import reactor.core.publisher.Mono;
  13. import java.io.UnsupportedEncodingException;
  14. import java.net.URI;
  15. import java.net.URLEncoder;
  16. @Requires(env = Environment.GOOGLE_COMPUTE)
  17. @Filter(patterns = "/google-auth/api/**")
  18. public class GoogleAuthFilter implements HttpClientFilter {
  19. private final BeanProvider<HttpClient> authClientProvider;
  20. public GoogleAuthFilter(BeanProvider<HttpClient> httpClientProvider) { (1)
  21. this.authClientProvider = httpClientProvider;
  22. }
  23. @Override
  24. public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
  25. ClientFilterChain chain) {
  26. return Mono.fromCallable(() -> encodeURI(request))
  27. .flux()
  28. .flatMap(uri -> authClientProvider.get().retrieve(HttpRequest.GET(uri) (2)
  29. .header("Metadata-Flavor", "Google")))
  30. .flatMap(t -> chain.proceed(request.bearerAuth(t)));
  31. }
  32. private String encodeURI(MutableHttpRequest<?> request) throws UnsupportedEncodingException {
  33. URI fullURI = request.getUri();
  34. String receivingURI = fullURI.getScheme() + "://" + fullURI.getHost();
  35. return "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=" +
  36. URLEncoder.encode(receivingURI, "UTF-8");
  37. }
  38. }
  1. import io.micronaut.context.annotation.Requires
  2. import io.micronaut.context.env.Environment
  3. import io.micronaut.context.BeanProvider
  4. import io.micronaut.http.HttpResponse
  5. import io.micronaut.http.MutableHttpRequest
  6. import io.micronaut.http.annotation.Filter
  7. import io.micronaut.http.client.HttpClient
  8. import io.micronaut.http.filter.ClientFilterChain
  9. import io.micronaut.http.filter.HttpClientFilter
  10. import org.reactivestreams.Publisher
  11. import reactor.core.publisher.Flux
  12. import reactor.core.publisher.Mono
  13. import static io.micronaut.http.HttpRequest.GET
  14. @Requires(env = Environment.GOOGLE_COMPUTE)
  15. @Filter(patterns = "/google-auth/api/**")
  16. class GoogleAuthFilter implements HttpClientFilter {
  17. private final BeanProvider<HttpClient> authClientProvider
  18. GoogleAuthFilter(BeanProvider<HttpClient> httpClientProvider) { (1)
  19. this.authClientProvider = httpClientProvider
  20. }
  21. @Override
  22. Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
  23. ClientFilterChain chain) {
  24. Flux<String> token = Mono.fromCallable(() -> encodeURI(request))
  25. .flatMap(authURI -> authClientProvider.get().retrieve(GET(authURI).header( (2)
  26. "Metadata-Flavor", "Google"
  27. )))
  28. return token.flatMap(t -> chain.proceed(request.bearerAuth(t)))
  29. }
  30. private static String encodeURI(MutableHttpRequest<?> request) {
  31. String receivingURI = "$request.uri.scheme://$request.uri.host"
  32. "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=" +
  33. URLEncoder.encode(receivingURI, "UTF-8")
  34. }
  35. }
  1. import io.micronaut.context.BeanProvider
  2. import io.micronaut.context.annotation.Requires
  3. import io.micronaut.context.env.Environment
  4. import io.micronaut.http.HttpRequest
  5. import io.micronaut.http.HttpResponse
  6. import io.micronaut.http.MutableHttpRequest
  7. import io.micronaut.http.annotation.Filter
  8. import io.micronaut.http.client.HttpClient
  9. import io.micronaut.http.filter.ClientFilterChain
  10. import io.micronaut.http.filter.HttpClientFilter
  11. import org.reactivestreams.Publisher
  12. import reactor.core.publisher.Mono
  13. import java.net.URLEncoder
  14. @Requires(env = [Environment.GOOGLE_COMPUTE])
  15. @Filter(patterns = ["/google-auth/api/**"])
  16. class GoogleAuthFilter (
  17. private val authClientProvider: BeanProvider<HttpClient>) : HttpClientFilter { (1)
  18. override fun doFilter(request: MutableHttpRequest<*>,
  19. chain: ClientFilterChain): Publisher<out HttpResponse<*>?> {
  20. return Mono.fromCallable { encodeURI(request) }
  21. .flux()
  22. .map { authURI: String ->
  23. authClientProvider.get().retrieve(HttpRequest.GET<Any>(authURI)
  24. .header("Metadata-Flavor", "Google") (2)
  25. )
  26. }.flatMap { t -> chain.proceed(request.bearerAuth(t.toString())) }
  27. }
  28. private fun encodeURI(request: MutableHttpRequest<*>): String {
  29. val receivingURI = "${request.uri.scheme}://${request.uri.host}"
  30. return "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=" +
  31. URLEncoder.encode(receivingURI, "UTF-8")
  32. }
  33. }
1The BeanProvider interface is used to inject another client, avoiding a circular reference
2The get() method of the Provider interface is used to obtain the client instance.

Filter Matching By Annotation

For cases where a filter should be applied to a client regardless of the URL, filters can be matched by the presence of an annotation applied to both the filter and the client. Given the following client:

  1. import io.micronaut.http.annotation.Get;
  2. import io.micronaut.http.client.annotation.Client;
  3. @BasicAuth (1)
  4. @Client("/message")
  5. public interface BasicAuthClient {
  6. @Get
  7. String getMessage();
  8. }
  1. import io.micronaut.http.annotation.Get
  2. import io.micronaut.http.client.annotation.Client
  3. @BasicAuth (1)
  4. @Client("/message")
  5. interface BasicAuthClient {
  6. @Get
  7. String getMessage()
  8. }
  1. import io.micronaut.http.annotation.Get
  2. import io.micronaut.http.client.annotation.Client
  3. @BasicAuth (1)
  4. @Client("/message")
  5. interface BasicAuthClient {
  6. @Get
  7. fun getMessage(): String
  8. }
1The @BasicAuth annotation is applied to the client

The following filter will filter the client requests:

  1. import io.micronaut.http.HttpResponse;
  2. import io.micronaut.http.MutableHttpRequest;
  3. import io.micronaut.http.filter.ClientFilterChain;
  4. import io.micronaut.http.filter.HttpClientFilter;
  5. import org.reactivestreams.Publisher;
  6. import jakarta.inject.Singleton;
  7. @BasicAuth (1)
  8. @Singleton (2)
  9. public class BasicAuthClientFilter implements HttpClientFilter {
  10. @Override
  11. public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
  12. ClientFilterChain chain) {
  13. return chain.proceed(request.basicAuth("user", "pass"));
  14. }
  15. }
  1. import io.micronaut.http.HttpResponse
  2. import io.micronaut.http.MutableHttpRequest
  3. import io.micronaut.http.filter.ClientFilterChain
  4. import io.micronaut.http.filter.HttpClientFilter
  5. import org.reactivestreams.Publisher
  6. import jakarta.inject.Singleton
  7. @BasicAuth (1)
  8. @Singleton (2)
  9. class BasicAuthClientFilter implements HttpClientFilter {
  10. @Override
  11. Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
  12. ClientFilterChain chain) {
  13. chain.proceed(request.basicAuth("user", "pass"))
  14. }
  15. }
  1. import io.micronaut.http.HttpResponse
  2. import io.micronaut.http.MutableHttpRequest
  3. import io.micronaut.http.filter.ClientFilterChain
  4. import io.micronaut.http.filter.HttpClientFilter
  5. import org.reactivestreams.Publisher
  6. import jakarta.inject.Singleton
  7. @BasicAuth (1)
  8. @Singleton (2)
  9. class BasicAuthClientFilter : HttpClientFilter {
  10. override fun doFilter(request: MutableHttpRequest<*>,
  11. chain: ClientFilterChain): Publisher<out HttpResponse<*>> {
  12. return chain.proceed(request.basicAuth("user", "pass"))
  13. }
  14. }
1The same annotation, @BasicAuth, is applied to the filter
2Normally the @Filter annotation makes filters singletons by default. Because the @Filter annotation is not used, the desired scope must be applied

The @BasicAuth annotation is just an example and can be replaced with your own.

  1. import io.micronaut.http.annotation.FilterMatcher;
  2. import java.lang.annotation.Documented;
  3. import java.lang.annotation.Retention;
  4. import java.lang.annotation.Target;
  5. import static java.lang.annotation.ElementType.PARAMETER;
  6. import static java.lang.annotation.ElementType.TYPE;
  7. import static java.lang.annotation.RetentionPolicy.RUNTIME;
  8. @FilterMatcher (1)
  9. @Documented
  10. @Retention(RUNTIME)
  11. @Target({TYPE, PARAMETER})
  12. public @interface BasicAuth {
  13. }
  1. import io.micronaut.http.annotation.FilterMatcher
  2. import java.lang.annotation.Documented
  3. import java.lang.annotation.Retention
  4. import java.lang.annotation.Target
  5. import static java.lang.annotation.ElementType.PARAMETER
  6. import static java.lang.annotation.ElementType.TYPE
  7. import static java.lang.annotation.RetentionPolicy.RUNTIME
  8. @FilterMatcher (1)
  9. @Documented
  10. @Retention(RUNTIME)
  11. @Target([TYPE, PARAMETER])
  12. @interface BasicAuth {
  13. }
  1. import io.micronaut.http.annotation.FilterMatcher
  2. import kotlin.annotation.AnnotationRetention.RUNTIME
  3. import kotlin.annotation.AnnotationTarget.CLASS
  4. import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER
  5. @FilterMatcher (1)
  6. @MustBeDocumented
  7. @Retention(RUNTIME)
  8. @Target(CLASS, VALUE_PARAMETER)
  9. annotation class BasicAuth
1The only requirement for custom annotations is that the @FilterMatcher annotation must be present