Cette page décrit le flux complet d’un appel client REST Cyrano, du moment où l’interface est annotée à celui où la valeur typée est retournée. Tout est build-time-friendly : pas de réflexion dynamique, pas de classpath scan à chaud, pas de synchronized dans le chemin chaud.

Pipeline d’un appel

Diagram

1. Découverte : BCE Vauban

L’amorçage commence à process-classes quand Vauban exécute les Build Compatible Extensions enregistrées via META-INF/services/jakarta.enterprise.inject.build.compatible.spi.BuildCompatibleExtension.

CyranoRestClientExtension (dans cyrano-cdi-vauban) déclare deux hooks :

@Enhancement(types = Object.class, withAnnotations = RegisterRestClient.class)
public void enhanceClient(ClassConfig cfg, MessagerLogger log) {
    log.info("Discovered REST client interface: " + cfg.info().name());
    // mémorise pour @Synthesis
}

@Synthesis
public void synthesize(SyntheticComponents syn) {
    for (Class<?> iface : discoveredClients) {
        syn.addBean(iface)
           .type(iface)
           .qualifier(RestClient.Literal.INSTANCE)
           .scope(ApplicationScoped.class)
           .createWith(CyranoRestClientSyntheticCreator.class);
    }
}

@Validation (optionnel) vérifie qu’une URL de base est résolvable au build-time si la spec l’exige.

2. Création : CyranoRestClientSyntheticCreator

À la première résolution CDI de @Inject @RestClient UsersClient, le créateur synthétique est invoqué :

  1. Le scope d’instanciation et les paramètres optionnels sont récupérés via SyntheticBeanCreator.create(Instance<Object> lookup, Parameters params).

  2. CyranoBaseUriResolver.resolve(iface) agrège les sources d’URL (annotation @RegisterRestClient(baseUri), MP Config <configKey>/mp-rest/url, MP Config <fqn>/mp-rest/url) et applique la priorité spec.

  3. RestClientBuilder.newBuilder().baseUri(uri).build(UsersClient.class) est appelé. La résolution du builder passe par ServiceLoader sur RestClientBuilderResolver ; Cyrano publie le sien dans le module io.vidocq.cyrano.core via provides …​ with …​.

MP Config est consulté par réflexion sur org.eclipse.microprofile.config.ConfigProvider. cyrano-cdi-vauban ne déclare aucune dépendance compile sur microprofile-config-api — si l’API n’est pas chargeable au runtime, defaultMpConfigLookup() renvoie key → Optional.empty() (dégradation gracieuse documentée dans AGENTS.md).

3. Scan d’interface

CyranoInterfaceScanner lit les annotations JAX-RS de l’interface et de ses méthodes via l’API java.lang.reflect (uniquement pour les annotations — pas de réflexion dans le chemin chaud).

Pour chaque méthode, un RequestSpec immuable est construit :

public record RequestSpec(
    String httpMethod,                    // "GET", "POST", ...
    String pathTemplate,                  // "/users/{id}"
    List<ParamBinding> params,            // ordre = arguments de la méthode
    MediaType[] consumes,                 // @Consumes
    MediaType[] produces,                 // @Produces
    Map<String, String> staticHeaders,    // @ClientHeaderParam(value="...")
    Map<String, String> dynamicHeaders,   // @ClientHeaderParam(value="{method}")
    Class<?> declaredReturn,              // type de retour brut
    Type genericReturn                    // type générique (pour CompletionStage<List<X>>)
) {}

ParamBinding est une sealed interface : PathParamBinding, QueryParamBinding, HeaderParamBinding, CookieParamBinding, MatrixParamBinding, FormParamBinding, BeanParamBinding, BodyBinding. Le switch dans CyranoInvocationHandler est exhaustif (sealed types — vérifié par le compilateur).

4. Génération de proxy via Class-File API (JEP 484)

CyranoProxyGenerator émet le bytecode d’une classe nommée Cyrano$<SimpleName> dans le package de l’interface (via MethodHandles.privateLookupIn(iface, MethodHandles.lookup())). Le squelette généré est :

package com.example.client;

public final class Cyrano$UsersClient implements UsersClient {

    private final io.vidocq.cyrano.runtime.CyranoInvocationHandler $handler;

    public Cyrano$UsersClient(io.vidocq.cyrano.runtime.CyranoInvocationHandler handler) {
        this.$handler = handler;
    }

    @Override
    public UserDto findById(long id) {
        return (UserDto) $handler.invoke(0, new Object[] { id });
    }
}

L’émission utilise l’API java.lang.classfile.ClassFile.build(…​). Aucune dépendance ASM, aucun setAccessible(true). La classe est chargée avec lookup.defineClass(bytes) ; le MethodHandles.Lookup est obtenu via privateLookupIn sur l’interface.

CyranoProxyCache mémoïse l'`Entry` (couple Class<?> proxyClass, MethodHandle constructor) dans un ConcurrentHashMap ; computeIfAbsent garantit qu’une interface est traitée une seule fois, même sous charge concurrente.

5. Invocation : CyranoInvocationHandler

Le MethodHandle du constructeur reçoit le CyranoInvocationHandler au moment de l’instanciation du proxy. À chaque appel, invoke(int methodIndex, Object[] args) :

  1. Récupère le RequestSpec indexé.

  2. Itère sur les ParamBinding pour construire l’URI (path + query + matrix), les headers, le corps.

  3. Évalue les @ClientHeaderParam dynamiques en invoquant la méthode default correspondante via MethodHandle.

  4. Sérialise le corps via Jakarta JSON-B (Jsonb.toJson(body)).

  5. Construit la java.net.http.HttpRequest (HttpRequest.newBuilder().uri(uri).method(verb, BodyPublishers.ofString(json)).headers(…​)).

6. Transport : CyranoHttpTransport

Un seul HttpClient par client REST, construit ainsi :

HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)   // tente H/2, fallback HTTP/1.1
    .connectTimeout(connectTimeout)
    .executor(Executors.newVirtualThreadPerTaskExecutor())
    .followRedirects(redirect)
    .proxy(proxySelector)
    .sslContext(sslContext)               // si trust/keystore configurés
    .build();

Mode synchrone : client.send(req, BodyHandlers.ofByteArray()) — appel bloquant sur un virtual thread (sans monopoliser un thread plateforme).

Mode async (CompletionStage<T>) : client.sendAsync(req, BodyHandlers.ofByteArray()) — le CompletableFuture interne est complété par le pool virtual-thread de HttpClient.

7. Désérialisation et exception mapping

Après réception, CyranoInvocationHandler :

  1. Applique les ClientResponseFilter enregistrés.

  2. Vérifie si un ResponseExceptionMapper qui handles(status, headers) existe (priorité croissante). Si oui : throw mapper.toThrowable(response).

  3. Si statut ≥ 400 et aucun mapper personnalisé : par défaut, throw new WebApplicationException(response).

  4. Sinon, désérialise le corps via JSON-B selon le type de retour générique (Jsonb.fromJson(body, genericReturn)).

  5. Pour CompletionStage<T>, encapsule dans un CompletableFuture déjà résolu / async selon le mode.

Coût mémoire et performance

  • Une classe générée par interface client (pas une par appel). Le CyranoProxyCache garantit la mémoïsation.

  • Une instance d'`HttpClient` par client REST, partagée entre tous les appels.

  • Aucun ThreadLocal, aucun synchronized — pas de pin sur virtual thread.

  • Pas de copie de buffer dans le chemin chaud : BodyHandlers.ofByteArray() puis désérialisation streaming JSON-B.

Décisions de conception

  • record + sealed interface pour RequestSpec et ParamBinding — pattern matching exhaustif, immutabilité, lisibilité.

  • Pas de java.lang.reflect.Proxy — incompatible AOT, stack traces obscures ($Proxy0.invoke).

  • Pas de pool plateformeExecutors.newVirtualThreadPerTaskExecutor() est suffisant et plus efficace pour de l’I/O.

  • Module repackage cyrano-mp-rest-client-api — la spec upstream est livrée sans module-info ni Automatic-Module-Name, ce qui interdit jlink. Le repackage la rend explicitement io.vidocq.cyrano.mp.rest.client.api.

La stratégie de génération de proxy est documentée dans les ADR (ADR-001-classfile-proxy-strategy.md).