This page describes the full pipeline of a Cyrano REST call, from the moment the interface is annotated to the moment the typed value is returned. Everything is build-time-friendly: no dynamic reflection, no hot classpath scan, no synchronized on the hot path.

Call pipeline

Diagram

1. Discovery: Vauban BCE

Bootstrap starts at process-classes when Vauban runs the Build Compatible Extensions registered through META-INF/services/jakarta.enterprise.inject.build.compatible.spi.BuildCompatibleExtension.

CyranoRestClientExtension (in cyrano-cdi-vauban) declares two hooks:

@Enhancement(types = Object.class, withAnnotations = RegisterRestClient.class)
public void enhanceClient(ClassConfig cfg, MessagerLogger log) {
    log.info("Discovered REST client interface: " + cfg.info().name());
    // memoise for @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 (optional) checks that a base URL is resolvable at build time if the spec requires it.

2. Creation: CyranoRestClientSyntheticCreator

On the first CDI resolution of @Inject @RestClient UsersClient, the synthetic creator is invoked:

  1. The instantiation scope and optional parameters are retrieved via SyntheticBeanCreator.create(Instance<Object> lookup, Parameters params).

  2. CyranoBaseUriResolver.resolve(iface) aggregates URL sources (annotation @RegisterRestClient(baseUri), MP Config <configKey>/mp-rest/url, MP Config <fqn>/mp-rest/url) and applies spec priority.

  3. RestClientBuilder.newBuilder().baseUri(uri).build(UsersClient.class) is called. The builder is resolved through ServiceLoader on RestClientBuilderResolver; Cyrano publishes its own in the io.vidocq.cyrano.core module via provides …​ with …​.

MP Config is consulted reflectively on org.eclipse.microprofile.config.ConfigProvider. cyrano-cdi-vauban declares no compile dependency on microprofile-config-api — if the API is not loadable at runtime, defaultMpConfigLookup() returns key → Optional.empty() (graceful degradation documented in AGENTS.md).

3. Interface scanning

CyranoInterfaceScanner reads the JAX-RS annotations on the interface and its methods via java.lang.reflect (annotation-only — no reflection on the hot path).

For each method, an immutable RequestSpec is built:

public record RequestSpec(
    String httpMethod,                    // "GET", "POST", ...
    String pathTemplate,                  // "/users/{id}"
    List<ParamBinding> params,            // order = method arguments
    MediaType[] consumes,                 // @Consumes
    MediaType[] produces,                 // @Produces
    Map<String, String> staticHeaders,    // @ClientHeaderParam(value="...")
    Map<String, String> dynamicHeaders,   // @ClientHeaderParam(value="{method}")
    Class<?> declaredReturn,              // raw return type
    Type genericReturn                    // generic type (for CompletionStage<List<X>>)
) {}

ParamBinding is a sealed interface: PathParamBinding, QueryParamBinding, HeaderParamBinding, CookieParamBinding, MatrixParamBinding, FormParamBinding, BeanParamBinding, BodyBinding. The switch in CyranoInvocationHandler is exhaustive (sealed types — compiler-checked).

4. Proxy generation through the Class-File API (JEP 484)

CyranoProxyGenerator emits the bytecode of a class named Cyrano$<SimpleName> in the interface’s package (via MethodHandles.privateLookupIn(iface, MethodHandles.lookup())). The generated skeleton is:

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 });
    }
}

Emission uses java.lang.classfile.ClassFile.build(…​). No ASM dependency, no setAccessible(true). The class is loaded with lookup.defineClass(bytes); the MethodHandles.Lookup is obtained via privateLookupIn on the interface.

CyranoProxyCache memoises the Entry (a Class<?> proxyClass, MethodHandle constructor pair) in a ConcurrentHashMap; computeIfAbsent guarantees that each interface is processed at most once, even under concurrent load.

5. Invocation: CyranoInvocationHandler

The constructor MethodHandle receives the CyranoInvocationHandler at proxy instantiation. On each call, invoke(int methodIndex, Object[] args):

  1. Retrieves the indexed RequestSpec.

  2. Iterates over the `ParamBinding`s to build the URI (path + query + matrix), headers and body.

  3. Evaluates dynamic @ClientHeaderParam by invoking the matching default method through a MethodHandle.

  4. Serialises the body via Jakarta JSON-B (Jsonb.toJson(body)).

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

6. Transport: CyranoHttpTransport

One HttpClient per REST client, built as:

HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)   // tries H/2, falls back to HTTP/1.1
    .connectTimeout(connectTimeout)
    .executor(Executors.newVirtualThreadPerTaskExecutor())
    .followRedirects(redirect)
    .proxy(proxySelector)
    .sslContext(sslContext)               // if trust/keystore configured
    .build();

Synchronous mode: client.send(req, BodyHandlers.ofByteArray()) — blocking call on a virtual thread (without monopolising a platform thread).

Async mode (CompletionStage<T>): client.sendAsync(req, BodyHandlers.ofByteArray()) — the internal CompletableFuture is completed by `HttpClient’s virtual-thread pool.

7. Deserialisation and exception mapping

After reception, CyranoInvocationHandler:

  1. Applies the registered `ClientResponseFilter`s.

  2. Checks whether a ResponseExceptionMapper that handles(status, headers) exists (ascending priority). If yes: throw mapper.toThrowable(response).

  3. If status ≥ 400 and no custom mapper applies: defaults to throw new WebApplicationException(response).

  4. Otherwise, deserialises the body via JSON-B according to the generic return type (Jsonb.fromJson(body, genericReturn)).

  5. For CompletionStage<T>, wraps in either an already-resolved CompletableFuture or an async one, depending on the mode.

Memory cost and performance

  • One generated class per client interface (not per call). CyranoProxyCache guarantees memoisation.

  • One HttpClient instance per REST client, shared across all calls.

  • No ThreadLocal, no synchronized — no virtual-thread pinning.

  • No buffer copy on the hot path: BodyHandlers.ofByteArray() followed by JSON-B streaming deserialisation.

Design decisions

  • record + sealed interface for RequestSpec and ParamBinding — exhaustive pattern matching, immutability, readability.

  • No java.lang.reflect.Proxy — AOT-incompatible, obscure stack traces ($Proxy0.invoke).

  • No platform poolExecutors.newVirtualThreadPerTaskExecutor() is sufficient and more efficient for I/O.

  • cyrano-mp-rest-client-api repackage module — the upstream spec ships without module-info and without Automatic-Module-Name, which prevents jlink. The repackage makes it explicitly io.vidocq.cyrano.mp.rest.client.api.

The proxy generation strategy is documented in the ADRs (ADR-001-classfile-proxy-strategy.md).