This page describes how an HTTP request flows through Cassini, the codegen-side implementation choices, and the integrations with Champollion (JSON) and Vauban (CDI). Source of truth: cassini-core/src/main/java/io/vidocq/cassini/internal.
Transport decoupling — golden rule
cassini-core contains no io.vidocq.chappe import. The transport is consumed via cassini-api (CassiniHttpExchange, CassiniHttpAdapter). This is a foundational constraint: a new transport (Netty, Helidon, Vert.x…) can be added without touching the engine.
┌─ cassini-api (public SPI)
│ ├── CassiniHttpExchange, CassiniHttpAdapter
│ ├── CassiniStack, ResourceFactory
│ └── (no dependency outside jakarta.ws.rs-api)
│
├─ cassini-core (impl, sealed)
│ ├── Invoker, UriRouter, ResourceScanner...
│ ├── CassiniRuntimeDelegate (base for transports)
│ └── exports internal.runtime to {tck, jdkhttp, chappe}
│
ServiceLoader─┬─ cassini-chappe ──→ requires cassini-api + cassini-core
│ provides RuntimeDelegate with ChappeRuntimeDelegate
│
├─ cassini-jdk-http ──→ requires cassini-api + cassini-core
│ provides RuntimeDelegate with JdkHttpRuntimeDelegate
│
├─ cassini-client ──→ requires cassini-api + cassini-core + java.net.http
│ provides ClientBuilder + RuntimeDelegate, uses Feature (VT-based)
│
├─ cassini-cdi-vauban ──→ requires cassini-api + io.vidocq.vauban.core
│ provides BeanProvider.Factory + BuildCompatibleExtension
│
└─ cassini-processor ──→ APT, provides javax.annotation.processing.Processor
cassini-maven-plugin (no module-info) — build-time codegen for dep JARs
Static resource codegen
Rather than reflecting on every dispatch, Cassini generates two artefacts per resource class, both resolved by name (Class.forName) at startup — never a classpath annotation scan:
-
<ResourceClass>$$CassiniAdapter— aResourceAdapter(SPIcassini-api, packageio.vidocq.cassini.spi.gen) that performs direct method invocation (noMethod.invoke), field injection of@Context/@*Param, and inline@*Paramcoercion: String/CharSequence, primitives + wrappers, enums (fromStringorEnum.valueOf), publicvalueOf/fromString/(String)-ctor types, andList/Set/SortedSet/Collectionof those. -
<ResourceClass>$$CassiniRoutes— aRouteProviderexposing the route table as string/class literals (RouteDescriptor).RouteRegistryconverts each to aResourceMethodvia a targetedgetDeclaredMethod(…)plusUriTemplate.compile(…)— no annotation scan. Classes carrying sub-resource locators sethasLocators()and fall back toResourceScannerfor the whole class.
Three generation tiers
Adapters come from one of three generators, most-preferred first (AdapterRegistry.lookup):
-
APT — compile time (
cassini-processor): emitsCassiniAdapter+` / `+CassiniRoutessource viaFilerfor every@Path/@Providerof the current build. Plain Java, no bytecode manipulation, AOT-safe. -
Maven plugin — build time (
cassini-maven-plugin:generate, bound toprocess-classes): pre-generates adapters for external archives (dependency JARs) by callingRuntimeAdapterGenerator.toBytecode(cls)and writing the.classfiles totarget/classes. AOT-safe. -
Runtime generator (
RuntimeAdapterGenerator.generate, Class-File API / JEP 484): JVM-only fallback when neither of the above produced an adapter.
AdapterRegistry.lookup tries Class.forName(<class>$$CassiniAdapter) first (APT/plugin output), then the runtime generator, then caches a SENTINEL so the request falls through to the reflective FieldInjector filet without retrying.
Documented residual reflection
Codegen does not erase reflection entirely; the remainder is one-time or structurally irreducible, never on the hot path:
-
ResourceScannerruns once at startup, only for classes without a generated$$CassiniRoutes(i.e. those with locators). -
Resource / bean instantiation (
getDeclaredConstructor().newInstance()) — one-time per scope; M6a prefers a generatednewInstance()factory when an accessible no-arg constructor exists. -
The
FieldInjectorfilet applies only when codegen cannot: a closed application module (noopens→privateLookupInfails), or a field whose type is a package-private class from another package than the resource (a Java language rule, not a bug).
Benefits: cold start under ~50 ms, AOT-compatible (GraalVM native-image, Leyden CDS), and predictable.
Champollion integration (JSON)
CassiniJsonbReaderWriter (in cassini-core/internal) implements MessageBodyReader<Object> + MessageBodyWriter<Object> for application/json and delegates to Champollion (Jsonb.create().toJson(…) / fromJson(…)).
The binding itself is static: champollion-codegen-apt emits a <Type>_Binding at compile time per serializable type. No runtime reflection, no on-the-fly setAccessible.
Vauban integration (CDI)
// SPI in cassini-api:
public interface BeanProvider {
interface Factory {
BeanProvider create();
}
Set<Class<?>> getResourceClasses();
<T> T resolve(Class<T> resourceClass);
}
cassini-cdi-vauban provides VaubanBeanProviderFactory via module-info.java:
provides io.vidocq.cassini.spi.bean.BeanProvider.Factory
with io.vidocq.cassini.cdi.vauban.VaubanBeanProviderFactory;
Cassini discovers Vauban via ServiceLoader, walks the BeanManager at stack construction, and stores @Path / @Provider classes. For every dispatch, resolve() calls container.select() — the CDI scope (@RequestScoped, @ApplicationScoped, …) is honoured.
The CassiniScopeExtension (Vauban Build Compatible Extension) adds @RequestScoped to @Path classes lacking an explicit scope — automatic alignment with the semantics filters and interceptors expect.
JAX-RS Client (cassini-client)
cassini-client is a standalone implementation of the Jakarta REST 4.0 Client API with no external dependency: it builds on the JDK java.net.http.HttpClient running on virtual threads. It exposes no public package — the whole surface is consumed through jakarta.ws.rs.client.* and discovered via ServiceLoader:
-
provides jakarta.ws.rs.client.ClientBuilder with CassiniClientBuilder—ClientBuilder.newClient()returns a Cassini client whenevercassini-clientis on the path. -
provides jakarta.ws.rs.ext.RuntimeDelegate with CassiniClientRuntimeDelegate— makes the module self-contained forUriBuilder.fromUri(…); all transports point to the sameCassiniRuntimeDelegate, so there is no runtime divergence when several are present. -
uses jakarta.ws.rs.core.Feature— third-partyFeature`s (MicroProfile Telemetry instrumentation, auth, logging…) registered via `ServiceLoaderare auto-applied byCassiniClientBuilder.build()on aFeatureContextadapter.
It reuses MessageBodyRegistry, CassiniResponse, CassiniUriBuilder and CassiniRuntimeDelegate from cassini-core — request/response (de)serialization is shared with the server side. ClientRequestFilter / ClientResponseFilter are @Priority-ordered; AsyncContextPropagator carries the async invocation onto a virtual thread.
Virtual threads and async
-
All blocking I/O (body read, backend wait, SSE) runs on a virtual thread created by
Executors.newVirtualThreadPerTaskExecutor(). -
@Suspended AsyncResponseimmediately releases the carrier thread; resolution is posted by user code on a VT. -
A
CompletionStagereturned by a resource method is propagated down to the transport viaCassiniAsyncContext.
Full chunked streaming (SSE backpressure, native StreamingOutput) is scheduled for M2h/M2i — see TCK status.
Extension points
|
Implement to wire a new transport. |
|
Implement to wire a different CDI/DI container. |
|
Override resource instantiation without a full CDI container. |
|
Extend serialization to new media types. |
|
Convert custom types in |