Skip to content

Auto-configuration for RestClient, WebClient and generated @HttpExchange proxies #42963

Open
@ch4mpy

Description

Spring Cloud offers some auto-configuration for its @FeignClient which entered maintenance mode in favor of RestClient and WebClient used with HttpServiceProxyFactory for @HttpExchange.

The new solution provides similar declarative REST client features, at the price of quite some Java conf especially when request authorization is involved - which should almost always be the case.

I experienced @HttpExchange proxies auto-configuration using application properties in this starter of mine and I think that the features I implemented are worth integrating into the "official" framework because they greatly improve developers' experience.

Sample

Use case

Let's consider the pool of oauth2ResourceServer microservices from this sample repository.

the 3 different declinations of the MicroserviceChouette*Application call:

  • MicroserviceMachinApplication  on behalf of the resource owner at the origin of the requests: the requests are authorized re-using the Bearer token in the security-context (MicroserviceChouetteApplication is a resource server, so the request it processes already is authorized with a Bearer token).
  • MicroserviceBiduleApplication in their own names: a new Bearer token is acquired using client-credentials flow.

The MicroserviceMachinApplication exposes an OpenAPI document from which we can generate the following:

@HttpExchange
public interface MachinApi {
  @GetExchange("/truc")
  String getTruc();
}

The MicroserviceBiduleApplication exposes an OpenAPI document from which we can generate the following:

@HttpExchange
public interface BiduleApi {
  @GetExchange("/chose")
  String getChose();
}

MicroserviceChouetteApplication collaborates with the two REST APIs above as follows:

@RestController
@RequiredArgsConstructor
public class ChouetteController {
  private final MachinApi machinApi;
  private final BiduleApi biduleApi;

  @GetMapping("/chouette-truc")
  public String getChouetteTruc() {
    return machinApi.getTruc();
  }

  @GetMapping("/chouette-chose")
  public String getChouetteChose() {
    return biduleApi.getChose();
  }
}

This requires implementations for MachinApi and BiduleApi to be exposed as beans, internally using a RestClient or WebClient instance to retrieve REST resources from other services - authorizing these requests with Bearer tokens.

Common security configuration

issuer: https://oidc.c4-soft.com/auth/realms/rest-showcase
bidule-api-port: 8081
machin-api-port: 8082

server:
  port: 8083

spring:
  application:
    name: bidule-api
  security:
    oauth2:
      client:
        provider:
          sso:
           issuer-uri: ${issuer}
        registration:
          bidule-registration:
            provider: sso
            authorization-grant-type: client_credentials
            client-id: chouette-api
            client-secret: change-me
            scope: openid
      resourceserver:
        jwt:
          issuer-uri: ${issuer}

REST configuration with just "official" 3.4.0-RC1 starters

I believe that we can hardly be more synthetic than the following for having MachinApi and BiduleApi implementations generated by HttpServiceProxyFactory, using RestClient instances configured with the required ClientHttpRequestInterceptors:

bidule-base-uri:  http://localhost:${bidule-api-port}
machin-base-uri:  http://localhost:${machin-api-port}
@Configuration
public class RestConfiguration {

  @Bean
  RestClient machinClient(@Value("${machin-base-uri}") URI machinBaseUri) {
    return RestClient.builder().baseUrl(machinBaseUri)
        .requestInterceptor(forwardingClientHttpRequestInterceptor()).build();
  }

  @Bean
  MachinApi machinApi(RestClient machinClient) {
    return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(machinClient)).build()
        .createClient(MachinApi.class);
  }

  @Bean
  RestClient biduleClient(@Value("${bidule-base-uri}") URI biduleBaseUri,
      OAuth2AuthorizedClientManager authorizedClientManager,
      OAuth2AuthorizedClientRepository authorizedClientRepository) {
    return RestClient.builder().baseUrl(biduleBaseUri)
        .requestInterceptor(registrationClientHttpRequestInterceptor(authorizedClientManager,
            authorizedClientRepository, "bidule-registration"))
        .build();
  }

  @Bean
  BiduleApi biduleApi(RestClient biduleClient) {
    return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(biduleClient)).build()
        .createClient(BiduleApi.class);
  }

  ClientHttpRequestInterceptor forwardingClientHttpRequestInterceptor() {
    return (HttpRequest request, byte[] body, ClientHttpRequestExecution execution) -> {
      final var auth = SecurityContextHolder.getContext().getAuthentication();
      if (auth != null && auth.getPrincipal() instanceof AbstractOAuth2Token oauth2Token) {
        request.getHeaders().setBearerAuth(oauth2Token.getTokenValue());
      }
      return execution.execute(request, body);
    };
  }

  ClientHttpRequestInterceptor registrationClientHttpRequestInterceptor(
      OAuth2AuthorizedClientManager authorizedClientManager,
      OAuth2AuthorizedClientRepository authorizedClientRepository, String registrationId) {
    final var interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
    interceptor.setClientRegistrationIdResolver((HttpRequest request) -> registrationId);
    interceptor.setAuthorizationFailureHandler(
        OAuth2ClientHttpRequestInterceptor.authorizationFailureHandler(authorizedClientRepository));
    return interceptor;
  }
}

Things get even more complicated if the ClientHttpRequestFactory needs configuration for connect timeout, read timeout, or HTTP or SOCKS proxy (reach a remote service like Google API).

REST configuration with spring-addons-starter-rest

The RestConfiguration becomes:

com:
  c4-soft:
    springaddons:
      rest:
        client:
          machin-client:
            base-url: ${machin-base-uri}
            authorization:
              oauth2:
                forward-bearer: true
          bidule-client:
            base-url: ${bidule-base-uri}
            authorization:
              oauth2:
                oauth2-registration-id: bidule-registration
@Configuration
public class RestConfiguration {
  @Bean
  BiduleApi biduleApi(RestClient biduleClient) throws Exception {
    return new RestClientHttpExchangeProxyFactoryBean<>(BiduleApi.class, biduleClient).getObject();
  }

  @Bean
  MachinApi machinApi(RestClient machinClient) throws Exception {
    return new RestClientHttpExchangeProxyFactoryBean<>(MachinApi.class, machinClient).getObject();
  }
}

Features

What is already implemented:

  • Expose RestClient or WebClient named beans preconfigured with:
      - A base URI that is likely to change from one deployment to another.
      - Request authorization with a choice of Basic and Bearer, and for the latter, the choice of forwarding the token in the security context of a resource server, or obtained using an OAuth2 client registration ID.
      - Set Proxy-Authorization header and configure a ClientHttpRequestFactory for HTTP or SOCKS proxy. Enabled by default if the standard HTTP_PROXY and NO_PROXY environment variables or custom application properties are set, but can be disabled on any auto-configured client.
  • Clients are RestClient by default in servlets and WebClients in Webflux apps, but any client can be switched to WebClient in servlets.
  • Choice to expose the builder instead of an already built RestClient or WebClient. This can be useful when some more configuration is needed than what the starter implements.
  • The default REST client bean name is the camelCase version of its ID in properties (with Builder suffix if expose-builder=true). A custom name can be defined in properties

Room for improvement: remove the need for the generated @HttpExchange proxies beans definition in the RestConfiguration. I haven't found how to properly post-process the BeanDefinitionRegistry. The additional properties could look like the following:

        service:
          machin-api:
            client-bean-name: machinClient
            http-exchange-class: com.c4soft.showcase.rest.MachinApi
          bidule-api:
            client-bean-name: biduleClient
            http-exchange-class: com.c4soft.showcase.rest.BiduleApi

The great point of using a client bean name (rather than a key under the client properties), is that it allows to use any REST client, which could be a bean exposed using an auto-configured builder or a completely hand-crafted one.

Additional context

I already asked for this in Spring Security issues. @sjohnr wrote that such auto-configuration requests better fit here, and also that this shouldn't be implemented in the "official" framework because this would be "programming with yaml".

I have a different opinion about such auto-configuration. To me, it is about:

  • Meeting the DRY principle. I don't want to repeat such Java configuration in each of my resource servers collaborating with other REST API(s).
  • Limiting static configuration in application code.
  • Flattening the learning curve and improving developer productivity. IDEs autocompletion and documentation features help much more when writing YAML configuration than when implementing Java configuration as the one above, which is far from trivial: we have to remember the name of several classes, which factory to use, which default property to override, which configuration trick is available in servlets or reactive apps, etc.
  • Reducing the impact of breaking changes with future Spring Security and Spring Web(flux) versions.

My starter is just fine for me and the (very) few teams getting to know it and accepting to use it, but I'm sure that many more would be glad to benefit from such auto-configuration using just the official Boot starters.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions