Aller au contenu

Spring Boot : factoriser et modulariser ses clients HTTP avec HttpExchange (guide avancé)

Avec Spring Boot 3 et HttpExchange, concevez des clients HTTP déclaratifs, modulaires et réutilisables. Découvrez comment factoriser votre code et améliorer la maintenabilité grâce à des pratiques avancées.

Spring Boot - HTTP Interface Avancé
Spring Boot - HTTP Interface Avancé

Sommaire

  1. Introduction 📚
  2. Plan de lecture rapide 🧭
  3. Le problème concret (et le gain attendu) 🎯
  4. Concepts clés à connaître 🧩
  5. Prérequis et dépendances 🧰
  6. Architecture cible 🏗️
  7. Mise en place pas à pas 🛠️
  8. Fil rouge : du contrôleur à l'API distante 🔁
  9. OAuth2 avec registration-id 🔐
  10. Traçabilité des appels sortants 🛰️
  11. Bonus : simuler @SpringQueryMap avec @QueryParams 🎁
  12. Auto-configuration reusable 🤖
  13. Bonus : nouvelle approche avec ApiClientFactory 🧱
  14. Gestion des erreurs, tests et debug
  15. Bonnes pratiques + checklist production 📌
  16. Glossaire express 📘
  17. Conclusion 🎯

Introduction 📚

Quand une application appelle plusieurs APIs externes, la même logique technique se répète souvent partout : URL de base, auth OAuth2, propagation de headers, logs, retries, mapping d'erreurs.

L'objectif de cet article est simple : mettre toute cette infrastructure technique au même endroit, dans une factory commune, pour garder des clients HTTP lisibles et des modules métier propres.

Avec Spring 6 / Boot 3 et HttpExchange, vous pouvez déclarer les endpoints en interface Java et centraliser la construction des clients.


Plan de lecture rapide 🧭

  • Vous découvrez le sujet : sections 1 à 8.
  • Vous visez la prod : sections 6, 9, 10, 14, 15.
  • Vous migrez depuis Feign : section 11.
  • Vous voulez un récap rapide : sections 15, 16, 17.

Le problème concret (et le gain attendu) 🎯

Avant (duplication)

  • Chaque client HTTP reconstruit sa config.
  • Chaque module recopie auth + headers + logs.
  • Les changements transverses (timeouts, propagation, OAuth2) deviennent coûteux.

Après (factorisation)

  • Une seule HttpInterfaceFactory pour tous les clients.
  • Un contrat unique de configuration : HttpInterfaceProperties.
  • Un ajout de client = interface + properties + bean.
flowchart LR
A["Avant: duplication"] --> B["Après: factory commune"]
B --> C["Moins de code"]
B --> D["Comportement homogène"]
B --> E["Maintenance simplifiée"]

Concepts clés à connaître 🧩

@HttpExchange

Vous décrivez un endpoint HTTP dans une interface Java.

HttpInterfaceProperties

Contrat minimal partagé par tous les clients (URL, auth, headers).

HttpInterfaceFactory

Composant unique qui construit les proxies HTTP Spring (HttpServiceProxyFactory) avec la même logique transverse.

// caption: Properties nécessaire pour les appels API
/**
 * Properties for an HTTP interface.
 */
public interface HttpInterfaceProperties {

  /**
   * Get the base URL of the HTTP interface.
   *
   * @return The base URL
   */
  URI baseUrl();

  /**
   * Get the registration ID for OAuth2 client registration.
   *
   * @return The registration ID, or null if not applicable
   */
  @Nullable
  String registrationId();

  /**
   * Get the list of headers to be propagated in requests.
   *
   * @return The list of propagated headers
   */
  List<PropagatedHeader> propagatedHeaders();

}

Prérequis et dépendances 🧰

  • Java 17+
  • Spring Boot 3.x (ou Spring Framework 6.x)
  • spring-boot-starter-web (ou spring-boot-starter-webflux)
  • spring-boot-starter-oauth2-client (si vous utilisez registration-id)
  • Variante B (section 13) : vous implémentez localement les classes ApiClientFactory, ApiClientsProperties, ClientProperties, NamedHttpInterfaceProperties
// caption: Ajout des dépendances dans le pom.xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

Architecture cible 🏗️

flowchart LR
A["Controller/Service"] --> B["Interface HttpExchange"]
B --> C["HttpInterfaceFactory"]
C --> D["RestClient configure"]
D --> E["API distante"]

Idée directrice : la complexité technique reste dans HttpInterfaceFactory; le métier reste dans les services (ou directement en contrôleur pour les cas simples).


Mise en place pas à pas 🛠️

flowchart LR
A["application.yml"] --> B["@ConfigurationProperties"]
B --> C["@HttpExchange"]
C --> D["HttpInterfaceFactory"]
D --> E["@Bean client"]
E --> F["Test d'appel"]

Avant de commencer, choisissez votre variante :

  • Variante A (sections 7 à 12) : factory locale simple, parfaite pour démarrer vite.
  • Variante B (section 13) : factory industrialisée avec ApiClientFactory, recommandée quand vous avez beaucoup de clients.
flowchart TD
A["Vous avez peu de clients et un besoin simple ?"] -->|Oui| B["Variante A"]
A -->|Non| C["Variante B"]
B --> D["Factory locale + properties par client"]
C --> E["ApiClientFactory + map api-configurations.clients"]

1) Centraliser la config dans application.yml

// caption: Configuration de l'API dans les properties
administration-api:
  baseUrl: https://jsonplaceholder.typicode.com
  registration-id: my-oauth2-client
  propagated-headers:
    - X_SUPERVISOR
    - USER_ID

2) Mapper la config dans une classe @ConfigurationProperties

// caption: Mapping de la configuration en classe Java
/**
 * Properties for configuring the Administration API client.
 *
 * @param baseUrl           The base URL of the Administration API.
 * @param registrationId    The registration ID for the Administration API.
 * @param propagatedHeaders The list of headers to be propagated in requests for the Administration API.
 */
@ConfigurationProperties("administration-api")
public record AdministrationApiClientProperties(
    URI baseUrl,
    String registrationId,
    List<PropagatedHeader> propagatedHeaders
  ) implements HttpInterfaceProperties {
}

3) Déclarer l'interface HTTP

// caption: Création du client + Propre annotation
/**
 * Administration API Client Interface
 */
@HttpExchange
public interface AdministrationApiClient {

  @GetExchange(url = "/users", accept = APPLICATION_JSON_VALUE)
  PageResult retrieveUsers(
    @QueryParams SearchUserCriteria criteria
  );
}

@QueryParams est résolu via un HttpServiceArgumentResolver custom (voir section Bonus).
Sans resolver custom, préférez des @RequestParam explicites.

Exemple minimal sans resolver custom (Pour info, Sonar mets des warnings au bout 7 propriétés):

// caption: Création du client
@HttpExchange
public interface AdministrationApiClient {
  @GetExchange(url = "/users", accept = APPLICATION_JSON_VALUE)
  PageResult retrieveUsers(
    @RequestParam("page") Integer page,
    @RequestParam("size") Integer size,
    @RequestParam("lastName") String lastName
  );
}

4) Construire tous les clients dans une factory commune

// caption: Création de la factory commune
/**
 * Factory for HTTP interface clients.
 */
@Component
@RequiredArgsConstructor
@Slf4j
@SuppressWarnings("java:S119")
public class HttpInterfaceFactory {
  private final RestClient.Builder restClientBuilder;
  private final OAuth2AuthorizedClientManager authorizedClientManager;
  private final OAuth2AuthorizedClientService authorizedClientService;
  private final ClientRegistrationRepository clientRegistrationRepository;
  private final ObjectProvider<ClientHttpRequestInterceptor> requestInterceptors;
  private final ObjectProvider<HttpServiceArgumentResolver> argumentResolvers;

  /**
   * Create an HTTP interface client.
   *
   * @param apiClientClass The API client class
   * @param properties     The HTTP interface properties
   * @param <ApiClient>    The API client type
   * @return The created API client
   */
  public <ApiClient> ApiClient createClient(
    Class<ApiClient> apiClientClass,
    HttpInterfaceProperties properties
  ) {
    return createClient(apiClientClass, properties, List.of());
  }

  /**
   * Create an HTTP interface client.
   *
   * @param apiClientClass        The API client class
   * @param properties            The HTTP interface properties
   * @param restClientCustomizers The REST client customizers
   * @param <ApiClient>           The API client type
   * @return The created API client
   */
  public <ApiClient> ApiClient createClient(
    Class<ApiClient> apiClientClass,
    HttpInterfaceProperties properties,
    List<Consumer<RestClient.Builder>> restClientCustomizers
  ) {
    var clientBuilder = restClientBuilder.clone();

    clientBuilder.baseUrl(properties.baseUrl().toString());

    if (StringUtils.hasText(properties.registrationId())) {
      ClientRegistration registration = clientRegistrationRepository.findByRegistrationId(properties.registrationId());
      if (registration == null) {
        throw new IllegalArgumentException("Unknown OAuth2 registration-id: " + properties.registrationId());
      }
      switch (registration.getAuthorizationGrantType().getValue()) {
        case "delegate" -> clientBuilder.requestInterceptor(new OAuth2DelegationRestTemplateInterceptor(
          authorizedClientManager,
          authorizedClientService,
          registration
        ));

        case "client_credentials" -> clientBuilder.requestInterceptor(new OAuthClientCredentialsRestTemplateInterceptor(
          authorizedClientManager,
          authorizedClientService,
          registration
        ));

        default -> throw new IllegalArgumentException("Unknown authorization grant type");
      }
    }

    clientBuilder.requestInterceptor(new PropagatedHeaderInterceptor(properties.propagatedHeaders()));

    requestInterceptors.forEach(clientBuilder::requestInterceptor);
    restClientCustomizers.forEach(clientBuilder::apply);

    var proxyBuilder = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(clientBuilder.build()));

    argumentResolvers.forEach(proxyBuilder::customArgumentResolver);

    return proxyBuilder.build().createClient(apiClientClass);
  }
}

5) Exposer le bean client

// caption: Création du client à travers un Bean
/**
 * Configuration class to set up the AdministrationApiClient bean.
 */
@Configuration
@EnableConfigurationProperties(AdministrationApiClientProperties.class)
public class AdministrationApiClientConfiguration {
  @Bean
  public AdministrationApiClient administrationApiClient(
    HttpInterfaceFactory factory,
    AdministrationApiClientProperties properties
  ) {
    return factory.createClient(
      AdministrationApiClient.class,
      properties
    );
  }
}

6) Ne pas oublier l'application principale

Si vous centralisez vos @ConfigurationProperties, déclarez-les aussi dans la classe main.

// caption: Activer la configuration des properties
/**
 * Main application class for the Sfeir application.
 */
@EnableScheduling
@EnableConfigurationProperties
@PropertySource("classpath:git.properties")
@SpringBootApplication
public class SfeirApplication {
  /**
   * Main method to run the Sfeir application.
   *
   * @param args command-line arguments
   */
  public static void main(String[] args) {
    SpringApplication.run(SfeirApplication.class, args);
  }
}

Fil rouge : du contrôleur à l'API distante 🔁

Variante A : appel direct (cas simple)

// caption: Exemple: Controlleur d'Administration
/**
 * Administration Controller
 */
@RestController
@RequestMapping("/api/v2/administration")
@Tag(name = "Administration")
@RequiredArgsConstructor
@SupervisionAware(headerName = ACCESS_TYPE_HEADER_NAME_V2)
@UserIdAware
public class AdministrationController {

  private final AdministrationApiClient administrationApi;

  @GetMapping(path = "/users", produces = APPLICATION_JSON_VALUE)
  @Operation(
    summary = "Retrieve users",
    description = "Call directly the User Management API to retrieve users with pagination and filtering criteria"
  )
  @Example(name = "Nominal", resource = "/examples/administration/user/retrieve/nominal.json")
  @Example(name = "Minimal", resource = "/examples/administration/user/retrieve/minimal.json")
  public PageResult retrieveUsers(
    @ParameterObject SearchUserCriteria criteria
  ) {
    return administrationApi.retrieveUsers(criteria);
  }
}

Variante B : via service (cas métier)

Conservez un UserService si vous devez :

  • orchestrer plusieurs APIs,
  • appliquer des règles métier,
  • transformer des DTOs.
sequenceDiagram
participant C as Controller
participant S as Service (optionnel)
participant A as AdministrationApiClient
participant R as API distante
C->>S: (optionnel) getUsers(criteria)
S->>A: retrieveUsers(criteria)
A->>R: GET /users?... 
R-->>A: payload JSON
A-->>S: PageResult
S-->>C: PageResult

Exemple d'appel de bout en bout

// caption: Appel vers l'API depuis un CURL
curl -X GET "http://localhost:8080/api/v2/administration/users?page=0&size=20&lastName=Smith" \
  -H "X-Request-Id: 6f6f7d8e-3e6a-4f5b-9f08-6b7ef46a5abc" \
  -H "User-Id: 123456"

Résultat attendu :

  • le contrôleur reçoit la requête entrante ;
  • AdministrationApiClient appelle l'API distante ;
  • les headers propagés (X-Request-Id, User-Id) sont transmis ;
  • la réponse JSON est remontée en PageResult.

OAuth2 avec registration-id 🔐

YAML complet de référence

// caption: Enregistrer les clients au niveau de Spring Security
spring:
  security:
    oauth2:
      client:
        registration:
          my-oauth2-client:
            provider: my-oauth2-provider
            client-id: ${app-provider-name-config.client-id}
            client-secret: ${app-provider-name-config.client-secret}
            authorization-grant-type: client_credentials
        provider:
          my-oauth2-provider:
            issuer-uri: ${app-provider-name-config.root-uri}

administration-api:
  baseUrl: https://jsonplaceholder.typicode.com
  registration-id: my-oauth2-client
sequenceDiagram
participant F as HttpInterfaceFactory
participant M as OAuth2AuthorizedClientManager
participant P as OAuth2 Provider
participant A as API distante
F->>M: resolve registration-id
M->>P: token request (client_credentials)
P-->>M: access_token
M-->>F: bearer token
F->>A: requête HTTP + Authorization

administration-api.registration-id doit correspondre exactement à une entrée de
spring.security.oauth2.client.registration.

Note: le grant delegate montré dans la factory est un cas spécifique projet.
En standard OAuth2, vous utiliserez le plus souvent client_credentials.


Traçabilité des appels sortants 🛰️

Objectif : propager automatiquement X-REQUEST-ID pour corréler les logs entrée/sortie.

flowchart LR
A["Requête entrante"] --> B["Filter: lit/génère X-REQUEST-ID"]
B --> C["ThreadLocal + MDC"]
C --> D["Interceptor sortant"]
D --> E["Requête vers API avec X-REQUEST-ID"]

Code PropagatedHeaderInterceptor

// caption: Propagation des headers dans les appels APIs
/**
 * Interceptor to propagate specified headers in HTTP requests.
 *
 * @param propagatedHeaders List of headers to be propagated.
 */
public record PropagatedHeaderInterceptor(
    Set<PropagatedHeader> propagatedHeaders
  ) implements ClientHttpRequestInterceptor {

  public PropagatedHeaderInterceptor {
    propagatedHeaders = Optional
      .ofNullable(propagatedHeaders)
      .map(PropagatedHeaderInterceptor::addRequestId)
      .orElse(Set.of(PropagatedHeader.REQUEST_ID));
  }

  @Override
  public @NonNull ClientHttpResponse intercept(
    @NonNull HttpRequest request,
    @NonNull byte[] body,
    @NonNull ClientHttpRequestExecution execution
  ) throws IOException {
    propagatedHeaders
      .forEach(header -> setHeaderValue(request, header));
    return execution.execute(request, body);
  }

  private static Set<PropagatedHeader> addRequestId(Set<PropagatedHeader> headers) {
    var result = new LinkedHashSet<>(headers);
    result.add(PropagatedHeader.REQUEST_ID);
    return result;
  }

  private static void setHeaderValue(HttpRequest request, PropagatedHeader header) {
    if (Objects.isNull(header.getHeaderValue())) {
      return;
    }
    request.getHeaders().set(header.getHeaderName(), header.getHeaderValue());
  }
}

Code PropagatedHeaderConstants

// caption: Centraliser les noms des headers
/**
 * Constants for propagated header names used in the application.
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class PropagatedHeaderConstants {
  public static final String USER_ID_HEADER_NAME = "User-Id";
  public static final String REQUEST_ID_HEADER_NAME = "X-Request-Id";
}

Code PropagatedHeader

// caption: Centraliser les valeurs des headers
/**
 * Enumeration of propagated headers with their corresponding header names and value retrieval methods.
 */
@RequiredArgsConstructor
public enum PropagatedHeader {
  USER_ID(USER_ID_HEADER_NAME) {
    @Override
    public String getHeaderValue() {
      return UserIdContextHolder.getUserId();
    }
  },
  REQUEST_ID(REQUEST_ID_HEADER_NAME) {
    @Override
    public String getHeaderValue() {
      return Optional.ofNullable(RequestIdContextHolder.getRequestId())
          .map(UUID::toString).orElse(null);
    }
  };

  @Getter
  private final String headerName;

  public abstract String getHeaderValue();
}

Bonus : simuler @SpringQueryMap avec @QueryParams 🎁

Spring HTTP Interface ne propose pas nativement l'équivalent exact de @SpringQueryMap.
Une approche classique consiste à introduire une annotation custom @QueryParams + resolver.

Annotation

// caption: Création d'une annotation
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Marks a method parameter (object or record) whose fields should be flattened into
 * individual HTTP query parameters by {@link QueryParamsArgumentResolver}.
 *
 * <p>Supported field types: primitives, their wrappers, {@link String},
 * and arrays / collections of the above.
 *
 */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface QueryParams {
}

Resolver

// caption: Transformer l'objet en query params
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.lang.NonNull;
import org.springframework.web.service.invoker.HttpRequestValues;
import org.springframework.web.service.invoker.HttpServiceArgumentResolver;

import java.util.function.BiConsumer;
import java.util.function.Consumer;

/**
 * Resolves method arguments annotated with {@link QueryParams} by converting them to request parameters.
 */
@RequiredArgsConstructor
public class QueryParamsArgumentResolver implements HttpServiceArgumentResolver {

  private final ObjectMapper mapper;

  @Override
  public boolean resolve(Object argument, MethodParameter parameter, @NonNull HttpRequestValues.Builder requestValues) {
    if (parameter.hasParameterAnnotation(QueryParams.class)) {
      try {
        listEntries(mapper.valueToTree(argument), requestValues::addRequestParameter);
        return true;
      } catch (UnresolvableNodeTypeException e) {
        throw new IllegalStateException(String.format(
          "Error when resolving %s argument. It must map to a JSON object whose values are primitives or arrays of primitives",
          parameter.getParameterName()
        ));
      }
    } else {
      return false;
    }
  }

  private static class UnresolvableNodeTypeException extends Exception {
  }

  /**
   * Lists all entries in the given JSON node, calling the given consumer for each key/value pair.
   *
   * @param node     The JSON node
   * @param consumer The consumer to call for each key/value pair
   * @throws UnresolvableNodeTypeException If the node is not an object or contains non-primitive/non-array values
   */
  private static void listEntries(JsonNode node, BiConsumer<String, String> consumer) throws UnresolvableNodeTypeException {
    if (node.isNull()) {
      return;
    }
    if (!node.isObject()) {
      throw new UnresolvableNodeTypeException();
    }
    for (var entry : node.fields()) {
      var key = entry.getKey();
      listValues(entry.getValue(), value -> consumer.accept(key, value));
    }
  }

  /**
   * Lists all values in the given JSON node, calling the given consumer for each value.
   *
   * @param node     The JSON node
   * @param consumer The consumer to call for each value
   * @throws UnresolvableNodeTypeException If the node is not a value or an array of values
   */
  private static void listValues(JsonNode node, Consumer<String> consumer) throws UnresolvableNodeTypeException {
    if (node.isValueNode()) {
      exportPrimitive(node, consumer);
    } else if (node.isArray()) {
      for (var element : node) {
        exportPrimitive(element, consumer);
      }
    } else {
      throw new UnresolvableNodeTypeException();
    }
  }

  /**
   * Exports the given primitive JSON node by calling the given consumer with its string representation.
   *
   * @param node     The JSON node
   * @param consumer The consumer to call with the string representation
   * @throws UnresolvableNodeTypeException If the node is not a primitive value
   */
  private static void exportPrimitive(JsonNode node, Consumer<String> consumer) throws UnresolvableNodeTypeException {
    if (node.isNull()) {
      return;
    }
    if (!node.isValueNode()) {
      throw new UnresolvableNodeTypeException();
    }
    consumer.accept(node.asText());
  }
}

Limite de cette version : objets imbriqués non supportés.

Exemple direct d'utilisation de @QueryParams

// caption: Utilisation de l'annotation pour une recherche
public record SearchUserCriteria(
  Integer page,
  Integer size,
  String lastName,
  List<String> roles
) {
}

@HttpExchange
public interface AdministrationApiClient {
  @GetExchange(url = "/users", accept = APPLICATION_JSON_VALUE)
  PageResult retrieveUsers(@QueryParams SearchUserCriteria criteria);
}

Auto-configuration reusable 🤖

// caption: Configuration automatique de l'instance
@AutoConfiguration
public class ApiClientAutoConfiguration {

  @Bean
  @ConditionalOnMissingBean
  public QueryParamsArgumentResolver queryObjectArgumentResolver(ObjectMapper objectMapper) {
    return new QueryParamsArgumentResolver(objectMapper);
  }
}

Pourquoi c'est utile ? Vous branchez une fois, puis chaque module peut réutiliser le même comportement.


Bonus : nouvelle approche avec ApiClientFactory 🧱

Pour des projets avec beaucoup de clients externes, une variante robuste consiste à centraliser la logique de factory HTTP dans des composants dédiés.

L'idée :

  • garder HttpInterfaceFactory minimal côté BFF,
  • centraliser OAuth2 + timeouts + logging dans ApiClientFactory,
  • piloter les clients via api-configurations.clients.<apiName>.

Dans cette version de l'article, vous pouvez tout implémenter dans votre projet (sans dépendance à une librairie externe).

Classes à avoir pour la variante B :

  • HttpInterfaceFactory (façade BFF)
  • ApiClientFactory (construction RestClient + proxy HttpExchange)
  • ApiClientsProperties (map api-configurations.clients)
  • ClientProperties (timeouts/OAuth2/logging)
  • NamedHttpInterfaceProperties (extension avec apiName())
  • AdministrationApiClientProperties et AdministrationApiClientConfiguration (wiring du client)
flowchart LR
A["Controller/Service"] --> B["HttpInterfaceFactory (BFF)"]
B --> C["ApiClientFactory (composant local)"]
C --> D["RestClient + OAuth2 + logs + timeouts"]
D --> E["Proxy HttpExchange"]
E --> F["API distante"]

1) HttpInterfaceFactory devient une façade simple

// caption: Création d'un client
/**
 * Factory for HTTP interface clients.
 */
@Component
@RequiredArgsConstructor
@Slf4j
@SuppressWarnings("java:S119")
public class HttpInterfaceFactory {

  private final ApiClientFactory apiClientFactory;

  /**
   * Create an HTTP interface client.
   *
   * @param apiClientClass        The API client class
   * @param properties            The HTTP interface properties
   * @param <ApiClient>           The API client type
   * @return The created API client
   */
  public <ApiClient> ApiClient createClient(
    Class<ApiClient> apiClientClass,
    HttpInterfaceProperties properties
  ) {
    var interceptor = new PropagatedHeaderInterceptor(Set.copyOf(properties.propagatedHeaders()));
    ApiClient httpExchangeClient = apiClientFactory.createHttpExchangeClient(apiClientClass, properties.apiName(), List.of(interceptor), List.of());
    return ThirdPartyApiCallException.decorateClient(apiClientClass, httpExchangeClient);
  }
}

2) ApiClientFactory industrialise la création des clients

// caption: Factoriser la création d'un client REST ou Http Exchange
/**
 * Factory that creates fully configured {@link RestClient} instances and Spring HTTP Interface
 * proxies.
 */
@RequiredArgsConstructor
public final class ApiClientFactory {

  private final ClientRegistrationRepository clientRegistrationRepository;
  private final OAuth2ClientHttpRequestInterceptorFactory interceptorFactory;
  private final ApiClientsProperties apiClientsProperties;
  private final QueryParamsArgumentResolver queryParamsArgumentResolver;
  private final RestClient.Builder restClientBuilder;

  public <T> T createHttpExchangeClient(
    @NonNull Class<T> clazz,
    @NonNull String apiName) {

    return createHttpExchangeClient(
      clazz,
      apiName,
      Collections.emptyList(),
      Collections.emptyList());
  }

  public <T> T createHttpExchangeClient(
    @NonNull Class<T> clazz,
    @NonNull String apiName,
    @NonNull List<ClientHttpRequestInterceptor> additionalInterceptors,
    @NonNull List<ClientHttpRequestInitializer> additionalInitializers) {

    var adapter = RestClientAdapter.create(
      createRestClient(
        apiName,
        additionalInterceptors,
        additionalInitializers));
    return HttpServiceProxyFactory.builderFor(adapter)
      .customArgumentResolver(queryParamsArgumentResolver)
      .build()
      .createClient(clazz);
  }

  public RestClient createRestClient(@NonNull String apiName) {
    return createRestClient(
      apiName,
      Collections.emptyList(),
      Collections.emptyList());
  }

  public RestClient createRestClient(
    @NonNull String apiName,
    @NonNull List<ClientHttpRequestInterceptor> additionalInterceptors,
    @NonNull List<ClientHttpRequestInitializer> additionalInitializers) {

    var props = resolveProperties(apiName);

    // See https://docs.spring.io/spring-boot/reference/io/rest-client.html
    // Spring Boot creates and pre-configures a prototype RestClient.Builder bean for you.
    // It is strongly advised to inject it in your components and use it to create RestClient instances.
    // Spring Boot is configuring that builder with HttpMessageConverters and an appropriate ClientHttpRequestFactory.
    var restClientBuilderTmp = restClientBuilder
      .clone()
      .baseUrl(props.getBaseURL())
      .requestFactory(buildRequestFactory(props))
      .requestInterceptor(new HttpClientLogger(apiName, props.getBaseURL(), props.getLogging()));

    if (props.isUseOauth2Interceptor()) {
      var registrationId = props.getRegistrationId() != null ? props.getRegistrationId() : apiName;
      var clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId);
      restClientBuilderTmp.requestInterceptor(
        CLIENT_CREDENTIALS.equals(clientRegistration.getAuthorizationGrantType())
          ? interceptorFactory.clientCredentials(clientRegistration)
          : interceptorFactory.delegation(clientRegistration));
    }

    additionalInterceptors.forEach(restClientBuilderTmp::requestInterceptor);
    additionalInitializers.forEach(restClientBuilderTmp::requestInitializer);
    return restClientBuilderTmp.build();
  }

  private ClientProperties resolveProperties(String apiName) {
    var props = apiClientsProperties.getClients().get(apiName);
    if (props == null) {
      throw new IllegalStateException(
        "No configuration found for API '%s'. Please declare 'api-configurations.clients.%s'."
          .formatted(apiName, apiName));
    }
    return props;
  }

  private static ClientHttpRequestFactory buildRequestFactory(ClientProperties props) {
    HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory();
    clientHttpRequestFactory.setConnectionRequestTimeout(Duration.ofSeconds(props.getConnectTimeoutInSec()));
    clientHttpRequestFactory.setReadTimeout(Duration.ofSeconds(props.getReadTimeoutInSec()));
    return clientHttpRequestFactory;
  }
}

En pratique, createRestClient(...) va:

  • résoudre les propriétés du client via ApiClientsProperties,
  • appliquer les timeouts (ClientProperties),
  • activer l'intercepteur OAuth2 selon registrationId/grant,
  • ajouter le logging HTTP.

⚠️ Dans cette variante, le contrat HttpInterfaceProperties évolue en général pour inclure apiName() (en plus des champs classiques), car la résolution passe par une map de clients nommés.

3) Modèle de configuration mutualisé

// caption: Création de la configuration global des APIs
@Getter
@Setter
@Validated
@ConfigurationProperties(prefix = "api-configurations")
public class ApiClientsProperties {

  @Valid
  private Map<String, ClientProperties> clients = new HashMap<>();
}

/**
 * Configuration properties for a single HTTP client.
 */
@Getter
@Setter
@Validated
public class ClientProperties {

  /**
   * Base URL of the remote API (e.g. {@code https://api.example.com}).
   */
  @NotBlank(message = "baseURL must not be blank")
  private String baseURL;

  /**
   * Set this property to false if you don't want to add delegation or client_credentials.
   * . Defaults to {@code true}.
   */
  private boolean useOauth2Interceptor = true;

  /**
   * OAuth2 registrationId. Used only if {#useOauth2Interceptor} is true.
   */
  private String registrationId;

  /**
   * TCP connect timeout in seconds. Defaults to {@code 5}.
   */
  @Positive
  private int connectTimeoutInSec = 5;

  /**
   * Socket read timeout in seconds. Defaults to {@code 60}.
   */
  @Positive
  private int readTimeoutInSec = 60;

  /**
   * Logging configuration for this client.
   */
  @NestedConfigurationProperty
  @Valid
  private HttpClientLogger.LoggerConfig logging = new HttpClientLogger.LoggerConfig();
}

Exemple application.yaml (version BFF):

// caption: Configuration global des APIs
api-configurations.clients:
  administration-api:
    baseUrl: ${api-configurations.clients.user-management-api.baseUrl}
    registration-id: sggc-api-delegate
    propagated-headers:
      - X_SUPERVISOR
      - USER_ID
    api-name: administration-api

Et les properties métier restent explicites :

// caption: Mapping des configurations de l'API
/**
 * Properties for configuring the Administration API client.
 *
 * @param baseUrl           The base URL of the Administration API.
 * @param registrationId    The registration ID for the Administration API.
 * @param propagatedHeaders The list of headers to be propagated in requests for the Administration API.
 * @param apiName           API name
 */
@ConfigurationProperties("api-configurations.clients.administration-api")
public record AdministrationApiClientProperties(
    String baseUrl,
    String registrationId,
    List<PropagatedHeader> propagatedHeaders,
    String apiName
  ) implements NamedHttpInterfaceProperties {
}

Mapper la config (extension de contrat pour la variante B):

// caption: Récupération des properties
/**
 * Extension of the base contract used by the industrialized variant.
 */
public interface NamedHttpInterfaceProperties extends HttpInterfaceProperties {

  /**
   * Logical API name used to resolve api-configurations.clients.<apiName>
   *
   * @return API name
   */
  String apiName();
}

AdministrationApiClientProperties implémente alors NamedHttpInterfaceProperties.

Et la configuration reste la même :

// caption: Création du bean pour le API client
/**
 * Configuration class to set up the AdministrationApiClient bean.
 */
@Configuration
@EnableConfigurationProperties(AdministrationApiClientProperties.class)
public class AdministrationApiClientConfiguration {
  @Bean
  public AdministrationApiClient administrationApiClient(
    HttpInterfaceFactory factory,
    AdministrationApiClientProperties properties
  ) {
    return factory.createClient(
      AdministrationApiClient.class,
      properties
    );
  }
}

4) Quand privilégier cette approche

  • Beaucoup d'APIs à connecter avec des règles transverses communes.
  • Besoin d'un standard unique sur OAuth2, timeouts, logs et resolvers.
  • Volonté d'ajouter un nouveau client avec un minimum de code spécifique.

Gestion des erreurs, tests et debug ✅

Gestion des erreurs

  • Capturez les statuts 4xx/5xx près du client.
  • Mappez vers des exceptions métier explicites.
  • Évitez de polluer les services avec de la technique HTTP.
flowchart LR
A["Erreur HTTP distante"] --> B{"Type d'erreur"}
B -->|"404"| C["UserNotFoundException"]
B -->|"4xx/5xx"| D["ExternalServiceException"]
C --> E["Service/Controller"]
D --> E

Exemple de mapping simple

// caption: Gestion des erreurs
public PageResult getUsers(SearchUserCriteria criteria) throws UserNotFoundException, ExternalServiceException {
  try {
    return administrationApiClient.retrieveUsers(criteria);
  } catch (HttpClientErrorException.NotFound e) {
    throw new UserNotFoundException("Aucun utilisateur trouvé", e);
  } catch (RestClientResponseException e) {
    throw new ExternalServiceException("Administration API en erreur: " + e.getRawStatusCode(), e);
  } catch (RestClientException e) {
    throw new ExternalServiceException("Administration API indisponible", e);
  }
}

Pièges fréquents

  • Oublier @EnableConfigurationProperties (ou le scan) et ne jamais instancier AdministrationApiClientProperties.
  • Mélanger @ParameterObject (doc OpenAPI) et @QueryParams (résolution HTTP Interface) côté client sortant.
  • Déclarer un registration-id absent de spring.security.oauth2.client.registration.
  • Ne pas propager X-REQUEST-ID, ce qui casse la corrélation des logs inter-services.

Vérification pas-à-pas (check rapide)

  • Variante A: application.yml charge la section administration-api
  • Variante B: application.yml charge api-configurations.clients.administration-api
  • AdministrationApiClientProperties est bien créé comme bean
  • AdministrationApiClientConfiguration expose AdministrationApiClient
  • HttpInterfaceFactory injecte bien interceptors + resolvers
  • un appel GET /api/v2/administration/users atteint l'API distante

Stratégie de tests recommandée

  • Unitaire : mock de AdministrationApiClient pour les services.
  • Contrat client : WireMock/MockWebServer pour valider URL, query params, headers.
  • Intégration : @SpringBootTest pour valider wiring + properties.

Exemple minimal de test de contrat (WireMock):

// caption: Test des APIs
@SpringBootTest(properties = {
  "administration-api.baseUrl=http://localhost:9090",
  "administration-api.registration-id="
})
@WireMockTest(httpPort = 9090)
class AdministrationApiClientContractTest {

  @Autowired
  private AdministrationApiClient administrationApiClient;

  @Test
  void should_forward_query_params_and_headers() {
    stubFor(get(urlPathEqualTo("/users"))
      .withQueryParam("page", equalTo("0"))
      .withQueryParam("size", equalTo("20"))
      .withHeader("User-Id", equalTo("123456"))
      .willReturn(okJson("{\"items\":[],\"total\":0}")));

    var response = administrationApiClient.retrieveUsers(
      new SearchUserCriteria(0, 20, "Smith", List.of())
    );

    assertThat(response).isNotNull();
    verify(getRequestedFor(urlPathEqualTo("/users")));
  }
}

Imports utiles (exemple): @WireMockTest, stubFor, get, urlPathEqualTo, equalTo, okJson, verify, getRequestedFor.

Debug utile

  • logging.level.org.springframework.web=DEBUG
  • Journaliser l'ID de corrélation (X-REQUEST-ID).

Bonnes pratiques + checklist production 📌

  • Externaliser toutes les URLs, credentials, timeouts.
  • Centraliser auth, headers, logs et retries dans la factory.
  • Garder la logique métier dans les services (quand nécessaire).
  • Ajouter une trace de corrélation à chaque appel sortant.
  • Écrire au moins un test de contrat par client.

Checklist pré-prod

  • [ ] @EnableConfigurationProperties bien configuré (main ou config cible)
  • [ ] clé YAML cohérente avec la variante choisie (administration-api ou api-configurations.clients.<apiName>)
  • [ ] registration-id cohérent avec Spring Security OAuth2
  • [ ] headers propagés testés (X-REQUEST-ID, etc.)
  • [ ] mapping d'erreurs métier valide
  • [ ] timeouts et retries vérifiés

Glossaire express 📘

  • HttpExchange : contrat déclaratif des appels HTTP.
  • HttpInterfaceFactory : usine qui crée les proxies clients.
  • HttpInterfaceProperties : contrat de config commun.
  • Propagated headers : headers recopiés d'un appel entrant vers un appel sortant.
  • MDC : contexte de logs pour tracer une requête de bout en bout.

Conclusion 🎯

Si vous retenez une seule idée : placez la complexité technique dans la factory, et gardez vos modules métier simples.

Avec cette organisation, vous gagnez en lisibilité, en cohérence transverse et en vitesse d'évolution quand un nouveau client API arrive.

Dernier