> ## Content Index
> Fetch the complete content index at: https://www.sfeir.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Spring Boot : factoriser et modulariser ses clients HTTP avec HttpExchange (guide avancé)
- URL: https://www.sfeir.dev/back/spring-boot-factoriser-et-modulariser-ses-clients-http-avec-httpexchange-guide-avance/
- Published: 2026-07-30T06:25:58.000Z
- Updated: 2026-07-30T06:25:57.000Z
- Description: 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.
- Author: Ranushan Rachu
- Tags: Back, #back, Spring Framework, Spring Boot, Java, HTTP Interface, #backend, REST

## Sommaire

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

---

## Introduction 📚

Quand une application appelle plusieurs [APIs](https://www.sfeir.dev/tag/api) externes, la même logique technique se répète souvent partout : URL de base, auth [OAuth2](https://www.sfeir.dev/back/securisez-vos-api-avec-spring-security-basic-auth), 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](https://www.sfeir.dev/tag/spring-boot) et `HttpExchange`, vous pouvez déclarer les endpoints en interface [Java](https://www.sfeir.dev/tag/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](https://www.sfeir.dev/tag/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.

```java
// 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](https://www.sfeir.dev/tag/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`

```xml
// 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`

```yaml
// 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`

```java
// 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

```java
// 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):

```java
// 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

```java
// 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

```java
// 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.

```java
// 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)

```java
// 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](https://www.sfeir.dev/tag/api),
- 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

```bash
// 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

```yaml
// 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](https://www.sfeir.dev/back/securisez-vos-api-avec-spring-security-jwt), 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`

```java
// 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`

```java
// 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`

```java
// 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

```java
// 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

```java
// 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`

```java
// 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 🤖

```java
// 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](https://www.sfeir.dev/back/elasticsearch-spring-boot-premiers-pas-partie-2))
- `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

```java
// 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

```java
// 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](https://www.sfeir.dev/tag/spring-security) selon `registrationId`/grant,
- ajouter le [logging HTTP](https://www.sfeir.dev/back/elasticsearch-spring-boot-premiers-pas-partie-2).

> ⚠️ 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é

```java
// 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):

```yaml
// 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 :

```java
// 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):

```java
// 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 :

```java
// 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](https://www.sfeir.dev/tag/api) à 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](https://www.sfeir.dev/tag/erreurs) 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

```java
// 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):

```java
// 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](https://www.sfeir.dev/back/junit-6-le-successeur-de-junit-5-est-la) 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](https://www.sfeir.dev/tag/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.