Grimm refuses per-request classpath scanning: all the work happens once, at CDI container startup, and the result is cached. This page describes the exact pipeline sequence, the role of the Vauban BCE, and the threading invariants.
grimm-core / grimm-cdi-vauban separation
grimm-core is plain Java. It knows nothing about CDI or JAX-RS runtime — only the spec types (@Path is read as an annotation, not as a handler). It is usable outside any container, for example by a Maven tool that produces an OpenAPI document at build time.
grimm-cdi-vauban is the integration layer: it plugs grimm-core into the Vauban Build Compatible Extension, exposes the /openapi resource, and supplies the Supplier<OpenAPI> consumed by downstream tools.
Build pipeline
Six steps, executed once when the container activates (ModelBuilder in grimm-core), with the result stored in GrimmModelCache:
-
ModelReaderInvoker— ifmp.openapi.model.readeris set, instantiates the class (CDI lookup preferred, no-arg constructor as fallback) and callsbuildModel(): the starting model. -
StaticFileReader— searches forMETA-INF/openapi.yaml,.yml, then.json. Read through the current classloader. ReturnsOptional<OpenAPI>. -
Annotation source — compile-time
$$GrimmModelfragments contributed bygrimm-processorare folded in first (ContributionRegistry+FragmentMerger);AnnotationScannerthen walks the remaining application classes recorded by the BCE —@Pathresources as well as@SchemaPOJOs and theApplicationsubclass — and builds a partialOpenAPI. The output is tagged byAnnotationSource(sealed sub-type ofModelSource). NEW The processor leaves to the runtime scan any class with ajakarta.validation.constraints.orjavax.validation.constraints.annotation on an operation parameter, as it does for the other constructs outside its subset, so the constraints the scan derives (minimum,pattern, …) reach the document. -
ModelMerger— merges the threeModelSourceinstances (sealed:StaticFileSource,ReaderSource,AnnotationSource) into a singleOpenAPI, with the spec priority annotations > static file > reader (the static file overrides the conflicting elements of the reader model, the annotations those of both). NEW The reader is invoked before the static file is read, and the merge order follows (it was static file, then reader, up to 0.3.0); see How the sources combine. -
ConfigApplier— applies the configuration overrides on the merged document:mp.openapi.servers,mp.openapi.servers.path.<path>,mp.openapi.servers.operation.<operationId>, and themp.openapi.schema.<FQCN>schema overrides (registered before the scan so that references resolve, applied here). -
FilterInvoker— ifmp.openapi.filteris set, loads theOASFilter(CDI lookup or no-arg constructor) and walks it across the tree. The filter is dispatched recursively via a typed visitor that `switch`es on the sealed model types.
The final OpenAPI is then stored in GrimmModelCache: a one-time initialisation guarded by a ReentrantLock, published through a volatile field.
Vauban BCE: GrimmExtension
The class discovery lives in io.vidocq.grimm.cdi.GrimmExtension, a Build Compatible Extension that uses exactly two CDI 4.1 Lite hooks — no @Synthesis, no synthetic bean:
@Discovery
public void resetForNewDeployment(ScannedClasses classes) {
// One container boot = one deployment: drop everything collected
// by a previous boot in the same JVM before this deployment's
// @Enhancement phase runs.
}
@Enhancement(types = Object.class, withSubtypes = true)
public void registerApplicationClass(ClassConfig classConfig) {
// Records every application class found by the CDI scanner —
// spec §4.4: the annotation scan covers all application classes,
// not only @Path resources. Grimm's own classes (io.vidocq.grimm.*)
// are skipped so the /openapi endpoint never documents itself.
}
NEW The recorded class names belong to the extension instance, so to one container. Its @Synthesis phase registers a synthetic ScannedTypes bean whose parameter lists them, and ScannedTypesCreator resolves them in the container, through the application’s class loader. The creator also adds every resource class grimm-processor described in the application’s META-INF/services/io.vidocq.grimm.spi.gen.OpenApiContribution: in annotated discovery mode a @Path class with no bean-defining annotation is not a bean, so the extension never sees it, and its generated description is what puts it in the document. Up to 0.3.0, the names lived in a static set, shared by every container of the class loader (grimm#22).
GrimmConfigProducer @Produces the GrimmConfig (read from MicroProfile Config). GrimmModelCache is a regular @ApplicationScoped bean, which takes the ScannedTypes through an Instance (the synthetic bean only exists once the extension ran) and whose @PostConstruct runs the pipeline once; getDocument() simply returns the cached reference.
Threading model
One-time locking, volatile reads
GrimmModelCache stores the document in a volatile field, initialised once in @PostConstruct behind a ReentrantLock (double-checked: initialize() is idempotent and subsequent calls are no-ops). After that build, getDocument() is a single volatile read on the fast path — the lock is only re-taken to lazily create an empty skeleton if the cache was never initialised. No synchronized anywhere, so a virtual thread never pins its carrier. The BCE guards its discovered-names set the same way, with a ReentrantLock held only during the container build phase.
Virtual threads for potentially blocking operations
Invoking an application-supplied OASModelReader or OASFilter may, in theory, trigger blocking calls (file or database reads). When the host runtime (Cassini + Chappe) runs on the default virtual executor (Executors.newVirtualThreadPerTaskExecutor()), these invocations inherit the virtual context — Grimm creates no platform-thread pool of its own.
YAML and JSON serialization
grimm-core ships two hand-written serializers (no SnakeYAML, no Jackson):
-
JsonSerializer— emits raw JSON 2020-12, dependency-free. -
YamlSerializer— emits OpenAPI 3.1-compatible YAML 1.2 with two-space indentation and block scalars for multi-line strings.
Symmetrically, JsonDeserializer and YamlDeserializer consume the static files.
OpenApiModelMapper and OpenApiValueMapper translate between the serialised tree and the internal OpenAPI model.
/openapi endpoint
OpenApiResource is an @ApplicationScoped JAX-RS bean in grimm-cdi-vauban. Its constructor injects GrimmModelCache. The getOpenApi(format, accept) method:
-
Resolves the format via
?format=(priority, spec §2.3) thenAccept(spec §2.2, YAML default). -
Reads the document from the cache.
-
Serialises it to YAML or JSON depending on the resolved format.
-
Returns a
Responsewith the matchingContent-Type.
No lock is taken; serialisation itself can parallelise safely.