REST JAX-RS
View MarkdownStatus: Stable Package:
dev.vertique.rest.jaxrsArtifact:vertique-rest-jaxrsDepends on: rest-core, security-core, input-processing, json API reference: javadoc.io
The JAX-RS routing runtime. vertique-rest-jaxrs turns annotated resource classes into plain Vert.x
routes, extracts and coerces method arguments, invokes the resource method, and dispatches whatever it
returns — an entity, a jakarta.ws.rs.core.Response, a Future, or an SSE stream — back onto the
wire. It also owns the error pipeline that every REST failure flows through, the JAX-RS
ExceptionMapper registry, and a minimal RuntimeDelegate so jakarta.ws.rs.core.Response works
without Jersey or RESTEasy on the classpath.
It is not the extension contract: RouterMount, Middleware, the interceptor SPIs, body decoders and
encoders, RestContextResolver, and the parameter-conversion stack are all declared in
dev.vertique:vertique-rest-core, and this module is one consumer of them. Request validation is
likewise a separate concern — the default web-validation gate lives in
dev.vertique:vertique-rest-validation. On the default path the build-time openapi.json is
documentation only; the opt-in openapi-contract strategy in
dev.vertique:vertique-rest-openapi-validation is the only mode that loads it at runtime.
When To Use It
Section titled “When To Use It”Include dev.vertique.rest.jaxrs.RestModule in the Dagger @Component for any application serving
HTTP endpoints from JAX-RS-annotated classes. RestModule includes RestCoreModule and
JsonRuntimeModule transitively, so it is the only REST module most applications name directly.
Applications using dev.vertique:vertique-starter-rest get it through
RestApplicationModule instead.
Pair it with dev.vertique:vertique-rest-validation (request validation and multipart file
constraints — without it, jaxrs.validationStrategy has only the built-in none strategy to select),
dev.vertique:vertique-validation (Bean Validation on method parameters),
dev.vertique:vertique-rest-security (authentication and authorization), and
dev.vertique:vertique-codegen-jaxrs (generated resource bindings and reflection-free dispatch).
Depend on dev.vertique:vertique-rest-core alone instead when writing a library that must compile
against the REST extension contract without pulling in the routing runtime.
Core Concepts
Section titled “Core Concepts”Routes are registered once, at startup
Section titled “Routes are registered once, at startup”JaxRsRouterMount is a RouterMount that builds one plain Vert.x sub-router. During
createRouter() it installs the body handler and upload cleanup, runs the router lifecycle hooks,
configures security-scheme handlers, and then hands the resource set to JaxRsRouteRegistrar.
For each method carrying an HTTP-verb annotation, its own or inherited, the registrar resolves an operationId,
builds a ResourceMethodMeta, validates the declaration, and installs the per-operation handler
chain:
operation-route identity (PlatformHandler) → auth handler(s) → @Consumes 415 gate → validation gate → OperationHandlerContributors → ResourceMethodInvokerThe operation-route identity handler records the operation’s route template and operation id for
the request’s completion event; as a Vert.x PlatformHandler it runs ahead of authentication, and
it never fails a request.
The @Consumes 415 gate checks body-carrying requests (POST, PUT, PATCH with a non-empty body)
against the operation’s declared media types. Wildcards are legal on the declaration only: a
declared application/* accepts any application/... request type. A request Content-Type is a
media type, not a range, so a missing, unparseable or wildcard one (*/*, application/*, or the
malformed */json) satisfies no declaration and is answered with 415. A body counts whether or not
the request declared its length: a non-empty body the body handler read always counts, so an HTTP/2
request with neither Content-Length nor Transfer-Encoding is gated like any other. Otherwise a
request has a body when it carries Transfer-Encoding or a non-zero Content-Length, and a
request the body handler treats as multipart or form (by its Content-Type) counts as a body. An
explicit Content-Length: 0 without Transfer-Encoding is an empty request and is not gated. The
broad ContentTypeValidationMiddleware applies the same rule. A declared @Consumes entry that
is not a media type (*/json) matches no request; startup logs a warning naming the operation, and
a route left with no usable entry answers every body request with 415.
The candidate methods are the ones the resource class and its superclasses declare, plus every
interface default method the class inherits without overriding. An annotated default method is a
route of each class that implements its interface, exactly as an override would be, which matches
Jakarta REST implementations such as Jersey and RESTEasy. A method declared by a class wins over an
interface default, and a default in a more specific interface wins over the one it overrides, so an
override never adds a second route. A superclass’s private method with the same signature is not
inherited and does not hide the default. (A package-private one in another package does: the JVM
dispatches the interface call to it and fails, so such a default is not routed.) A public (or
otherwise inherited) method declared by a superclass inherits method- and parameter-level JAX-RS
annotations from the interfaces the resource class implements — not only from interfaces of the
declaring superclass — so a Base.delete that implements Crud.delete for R extends Base implements Crud is routed with Crud’s @DELETE / @Path / @PathParam even when Base
implements nothing. The route takes the resource class’s class-level annotations
(@Path, security, media types) together with the default method’s merged method annotations.
A method inherits the annotations of every declaration it overrides — superclass methods, then the
methods of the interfaces — and a type variable bound in the resource class’s hierarchy is resolved
for that match: delete(String) of UserResource implements Crud<String> inherits @DELETE,
@Path, @RolesAllowed and the @PathParam binding of Crud<ID>.delete(ID), whether or not it
declares a verb of its own, and so does an override of a generic superclass method. A superclass
method that such an override inherits from is not routed a second time. The Java rules decide which
methods correspond (JLS 8.4.2 and 8.4.8.1): a generic method overrides a declaration with the same
type parameters and parameter types, a non-generic method may override a generic declaration by
erasure, purge(List<Integer>) is not an override of purge(T) in a Base<List<String>>
although both erase to List (and a private or static helper never is), and the declaration may be
public or protected, or package-private when it is inherited into the overriding method’s class: by a
class of its own package, or by a class of another package through a class of its package that
overrides it. A direct subclass in another package does not override a package-private method, as in
javac. The verb is the first of
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS found on the method or any declaration it overrides,
whichever declares it. A single-valued security attribute such as @RequiresAction is taken from the
nearest declaration (the override’s own wins over a declaration it overrides); differing
@RolesAllowed or @Authorized values across declarations fail startup (a compile error with the
processor). A method-level security annotation on an inherited declaration beats class-level
security, as it does on the method itself: an interface @PermitAll makes an unannotated override
public even under a class-level @RolesAllowed. What is not
resolved is the type of a method no class overrides: a non-overridden generic default (or generic
superclass method) binds a type-variable parameter as its erasure, so a path, query, header, cookie,
or form parameter typed ID fails startup with UNRESOLVABLE_PARAM_CONVERTER, and a body or return
type bound by a variable is Object. Override such a method with concrete parameter types. A
default route’s operationId is the same in every class that inherits
it, and operationIds are unique per mount, so only one resource per mount can inherit a given
default route; give the others their own route by overriding it with a distinct
@Operation(operationId = "…"). Once any @RestApplication is registered (active or not) or an
application mount is present, operationIds are unique across mounts too (the same-owner
exemption kept; see Startup failures), so only one resource class across all
JAX-RS mounts can inherit it.
OperationHandlerContributors are sorted by the framework OrderedExtension comparator (phase →
priority → orderKey); the invoker is always appended last. Every declaration problem found during
the scan is collected, and the whole set is thrown once as RouteRegistrationException after all
resources have been scanned — so a bad resource class reports all of its faults in one build cycle,
not one per restart.
The operationId comes from @Operation(operationId = "…") or falls back to the Java method name. It
identifies the route internally; it is not checked against the generated openapi.json at
runtime.
One response path for success and failure
Section titled “One response path for success and failure”Every response — a returned entity, a mapped exception, a 406 from content negotiation — is produced
as a jakarta.ws.rs.core.Response and then sent through one pipeline: transformResponse
interceptors → status and headers onto the wire → ResponseSerializer for the body →
afterResponse observers → wire-completion observation.
Two consequences matter to application code. afterResponse fires at handoff, while a streamed
body may still be in flight, because observers need the routing context and tracing span still
active. And status plus headers are already on the wire before a ResponseSerializer runs — a
serializer owns the body only. A post-handoff wire failure is recorded on the framework-owned
completion state through RequestCompletionRecorder.recordWireFailure (first writer wins), not
through a public RoutingContext.data() key; the completion event’s wireFailureCode reads that
marker, with a failed response end-handler result as the fallback, including the late-end()
carve-out documented on RestRequestCompletedEvent.
Per-request processing order
Section titled “Per-request processing order”1. Validate RequestValidationStrategy gate (annotation-synthesized schema by default)2. Decode RequestBodyDecoder chain → intermediate Map/List3. Canonicalize InputObjectProcessor (dev.vertique.input.processing): route-level → object-level + Sanitize → field-level canonicalizers, then route-level → object-level → field-level sanitizers4. Validate BeanValidator.validateParameters() (only when ValidationModule is present)5. Invoke the resource method, with processed and validated argumentsStep 3 is skipped transparently when SanitizationModule is absent and step 4 when
ValidationModule is absent; neither requires an application change.
@Canonicalize / @Sanitize apply at three scopes — on the resource class or method (route level,
covering all string values in the body plus scalar and collection-element parameters), on a DTO type
(object level), and on a field or record component (field level). @SkipCanonicalization /
@SkipSanitization opt an individual parameter out. Scalar and collection-element parameters receive
route-level chains only.
Policy resolution precedence. Route- and parameter-level @Canonicalize/@Sanitize chains are
derived through one shared resolver — the same one every other transport in the framework uses, not a
REST-only algorithm. Precedence is parameter > method > class: a parameter-level skip or declared
chain overrides the route (class/method) chain; on the route itself a method-level skip or declared
chain overrides the class-level one; with nothing declared anywhere the chain is empty.
Each level’s view of its own declaration is hierarchy-merged the same way annotation resolution works everywhere else in the framework: the declaring element first, then the same declaration on each superclass bottom-up, then on each interface the class transitively implements, with the first occurrence winning per element. An override may replace an inherited chain with a different one — the override wins — but may never remove it by pairing a skip annotation with an inherited declared chain on the other polarity; that combination is a conflict, not an opt-out.
A conflict — the additive annotation and the skip annotation both present, anywhere in one element’s
merged view — fails immediately and names both declaration sites, for example: Conflicting @Sanitize (declared on IFoo.bar) and @SkipSanitization (declared on FooImpl.bar) for method FooImpl.bar — an override cannot remove an inherited policy; remove one of the annotations. A class- or method-level
conflict fails resource scanning. A method-parameter conflict now fails at scan too, exactly like the
class/method case, instead of silently resolving to an empty chain. A conflict on a @BeanParam
field is rejected as well, a step later: it fails while the route’s parameter extractor is built
during route registration. Both are startup failures — declaring both @Sanitize and
@SkipSanitization (or both @Canonicalize and @SkipCanonicalization) on one parameter or one bean
field never reaches a request. A @Canonicalize/@Sanitize/@Skip* written on the @BeanParam
parameter itself is the composition baseline for that bean’s fields on both the reflective and
generated paths: parameter, bean-type, and field metadata are composed once into each field’s
effective chain and applied on the transport string before conversion (field chains append; field
skips suppress the corresponding axis). There is no second intermediate-map processing pass. The
startup gate that refuses an unbound engine when a route declares processing counts that parameter
chain, so a declared parameter policy cannot fail startup for work that would never run.
Which body shapes step 3 reaches. A DTO body, a collection or array body, a String body, a
form-urlencoded body bound to a POJO, and the schema-free JsonObject / JsonArray bodies all pass
through the engine — a Vert.x JSON wrapper is already the intermediate the engine walks, so a
declared chain reaches every string leaf in it, at any nesting depth. A schema-free body carries no
declared property set, so only route- and parameter-level chains apply to it and no wire-name
projection is consulted. A payload whose shape does not match the declared wrapper — an object body
on a JsonArray parameter, or an array body on a JsonObject one — is not processed at all: it
falls through to the decoder chain, which answers a shape mismatch with null exactly as it does
without the engine installed. A raw binary body (byte[], io.vertx.core.buffer.Buffer) is the one
shape the engine cannot process at all; declaring a chain on one is rejected at startup rather than
skipped (see Startup failures).
A policy declared on a parameter the engine never sees — a @Context parameter, a RequestPreconditions
parameter, or a raw multipart FileUpload / EntityPart parameter — is inert and is neither
processed nor reported: those values are not caller-supplied string input the chain model applies to.
Renamed body fields are covered. A field-level policy is declared on a Java property, while the
decoded body is keyed by whatever Jackson publishes — @JsonProperty("user_name"), a naming
strategy, @JsonNaming, a mix-in, or a @JsonAlias. The route’s resolved body mapper is introspected
at route registration and its projection maps each wire key back onto the Java property whose policies
apply (JacksonFieldNameResolver, from dev.vertique:vertique-json). A @Sanitize on a renamed
field therefore runs exactly as it would on an unrenamed one, with no extra declaration, and nothing
is introspected on the request path.
public record CreateUserRequest( @JsonProperty("display_name") @Sanitize(StripAllHtmlSanitizer.class) String displayName) {}// {"display_name": "<b>ada</b>"} -> displayName == "ada"Three shapes a declared policy still does not reach. A Map-typed field, an Object-typed
field, and a concrete @JsonTypeInfo subtype’s own fields all leave the field with its inherited
route- and object-level chains and nothing else. Nothing fails and nothing is logged, so a stranded policy
on one of these is invisible until the value that mattered gets through. A renamed key, a key a codec
promoted out of an @JsonUnwrapped member, and a key matched case-insensitively are all covered
now, and a governed field the mapper binds under a different property name — @Sanitize on
streetName behind setStreet — fails registration rather than passing silently. The vertique-input-processing reference documents each shape, what still applies, and how to
stay inside the covered set.
JSON profiles are symmetric
Section titled “JSON profiles are symmetric”A resource method’s request body and its response body use the same effective ObjectMapper. The
profile is resolved once at router-build time and reused for both directions; with nothing configured
that is the vertique floor, so a zero-config route binds Optional and java.time components and
renders without null fields — see Configuration.
Key Classes
Section titled “Key Classes”RestModule
Section titled “RestModule”The Dagger entry point, declared @Module(includes = {RestCoreModule.class, JsonRuntimeModule.class}).
@Singleton@Component(modules = { VertxModule.class, RestModule.class, // includes RestCoreModule and JsonRuntimeModule RestValidationModule.class, AuthModule.class, SecurityModule.class, AppModule.class, ResourceModule.class})interface AppComponent { HttpVerticle httpVerticle();}Resources reach it through the @JaxRsResources Set<Object> multibinding:
@Modulepublic abstract class ResourceModule { @Provides @IntoSet @JaxRsResources static Object helloResource(HelloResource resource) { return resource; }}dev.vertique:vertique-codegen-jaxrs generates those bindings for @Path classes as
GeneratedJaxRsResourcesModule; include it in the @Component and annotate a resource with
@NoAutoWire to keep a hand-written binding canonical instead. Applications inheriting
vertique-app-parent get the processor facade automatically; custom-parent applications follow the
BOM plus vertique-codegen-all recipe in docs/packaging.md, and applications using the
source-retained @NoAutoWire opt-out additionally declare vertique-codegen-core with provided
scope.
JaxRsRouterMount
Section titled “JaxRsRouterMount”The RouterMount implementation, constructed only through its injected inner Factory. Default
priority is 1000; meta() reports MountMeta("jaxrs:" + mountPath, mountPath, openapiPath, resourceTypes, applicationName). applicationName is the declared @RestApplication name for a mount
the framework’s application composer created, and null for a mount built through Factory.create and
for the zero-declaration default mount.
public static class Factory { public JaxRsRouterMount create(String mountPath, String openapiPath, Set<Object> resources); public JaxRsRouterMount create(String mountPath, String openapiPath, Set<Object> resources, int priority);}There is no public factory overload for a declared application’s mount: once one or more
@RestApplication interfaces are declared, the framework’s application composer creates one
mount per active application instead of any hand-built call above — see
@RestApplication.
With no @RestApplication declared (zero-declaration mode), RestModule contributes a default mount at
jaxrs.basePath (default /*) with jaxrs.openapiPath (default openapi.json) via
@ElementsIntoSet. The set is empty — no mount at all — when @JaxRsResources is empty. For a
single API, changing the prefix is a config edit:
{ "jaxrs": { "basePath": "/api/*" } }For several APIs on one server, inject the factory and contribute mounts explicitly:
@Provides @ElementsIntoSetstatic Set<RouterMount> mounts( JaxRsRouterMount.Factory factory, @V1Resources Set<Object> v1Resources, @V2Resources Set<Object> v2Resources) { return Set.of( factory.create("/v1/*", "openapi-v1.json", v1Resources, 100), factory.create("/v2/*", "openapi-v2.json", v2Resources, 200) );}openapiPath is consulted only by the opt-in openapi-contract validation strategy.
Multipart temp files are request-owned. BodyHandler spools uploads under http.uploadsDirectory
(default file-uploads) and cleanup is registered immediately after it, covering normal completion,
failure, connection close, and stream reset. Pre-auth spool admission for multipart/form-data is
bounded by http.maxMultipartBodySizeBytes (effective limit
min(http.maxBodySize, http.maxMultipartBodySizeBytes)); exceeding it returns 413, and when
Content-Length is present Vert.x rejects before creating upload files. The file stays readable while
the response streams, but an application that needs it afterwards must move or copy it before the
response completes. Retaining a FileUpload or a file-backed EntityPart does not extend the path’s
lifetime.
The application name on descriptors and interceptor contexts
Section titled “The application name on descriptors and interceptor contexts”An application mount carries its @RestApplication name onto every operation it registers. The name
is visible in these places:
MountMeta.applicationName()of the mount’smeta(), including theMountMetapassed toMountCustomizers and toRequestValidationStrategy.gateFor(op, schemas, mount).RestOperationDescriptor.applicationName()of every operation descriptor of that mount. This is the descriptor anOperationHandlerContributorreceives asOperationRegistrationContext.operation().OperationContext.operation()of everyOperationInterceptorcallback for that mount’s operations. The route registration builds one descriptor per operation and hands that same instance to the operation-route identity handler, to contributors, and to the operation’s invoker, so the descriptor a contributor received and the onectx.operation()returns are the same object.RestRequestCompletedEvent.operation()of every completion event for that mount’s operations, including a request rejected on its matched route (401, 403, 415, or 400): the operation-route identity handler records the same descriptor ahead of authentication.- Framework synthetic operations. The descriptor of a synthetic operation reports the name of the
application whose document the operation serves from
applicationName(). A synthetic route runs the contributor chain but not the operation interceptors, so it has noOperationContext. Its first handler is the completion recorder, ahead of authentication, so every request on the matched route, a401or403rejection included, completes as aRestRequestCompletedEventwhoseoperation()is the synthetic descriptor, the same instance its contributors receive. A synthetic operation created from a typed access policy (SyntheticOperation.withPolicy) reports the requirements that policy declares frommethodAnnotations(), behind the@SecurityRequirementof its scheme when it names one; contributors receive the policy’s action inOperationRegistrationContext.requiredAction().
The name is null on the mount metadata, the operation descriptors, and ctx.operation() for a mount
built through Factory.create and for the zero-declaration default mount; nothing derives a name from the mount path or the mount id.
Requests and configuration cannot set it, and no routing-context key publishes it: the only
per-request reference is the operation descriptor inside rest-core’s framework-owned completion
state, held under a key private to rest-core.
Interceptor chain. After each beforeOperation the chain compares the returned context’s
operation() with the operation of the context the chain started with, by reference. When they differ,
whether an interceptor built a five-argument OperationContext (which carries null) or a context
carrying another descriptor, the chain replaces the returned context with a copy that carries the
registration-time descriptor and keeps every other component, attributes included. Every later
beforeOperation, onOperation, afterOperation, onSuccess, onError, and recoverOperation
therefore observes the registration-time operation. Only the descriptor is restored: the operationId()
of a context an interceptor rebuilt stays as that interceptor returned it, so read identity from
ctx.operation().
ResourceMethodMeta
Section titled “ResourceMethodMeta”The immutable per-method descriptor built at startup and consumed on every request. It is the object
an OperationHandlerContributor receives through OperationRegistrationContext, so operationId()
and securityPolicy() in particular are read at extension-point call sites.
public record ResourceMethodMeta( Object resourceInstance, Method method, String operationId, String httpMethod, String path, List<ParamMeta> params, Class<?> responseBodyType, boolean returnsFuture, boolean returnsVoid, SecurityPolicy securityPolicy, // dev.vertique.rest.core.security MediaTypes mediaTypes, // nested record: (List<String> consumes, // List<String> produces); empty = unconstrained @Nullable Class<?>[] validationGroups, // from @ValidateWith; null = default group List<Annotation> methodAnnotations, List<Annotation> classAnnotations, List<Class<? extends Canonicalizer>> routeCanonicalizerChain, List<Class<? extends Sanitizer>> routeSanitizerChain, @Nullable ResourceExecutionPlan executionPlan) { … } // null = reflective dispatchA 16-component convenience constructor omits executionPlan. The compact constructor clones
validationGroups and copies the four lists, so every component is immutable regardless of what the
caller passes. methodAnnotations and classAnnotations are resolved through
dev.vertique.core.util.AnnotationResolver, which walks the superclass chain and interfaces — an
annotation on an interface method is visible here. For a class-declared method (including one
inherited from a superclass) the interface walk uses the resource class, so interfaces the resource
implements contribute annotations even when the declaring superclass does not. For a route backed by
an inherited interface default method, method() is the interface’s Method, so
method().getDeclaringClass() is the interface and the interface walk starts there;
resourceInstance().getClass() is the resource class. Security annotations from every declaration
of the same method or class are merged and then checked for conflicts (incompatible kinds, or
differing @RolesAllowed / @Authorized member values); a nearer declaration does not win. The
codegen path applies the same fail-closed rule.
ParamMeta describes one declared parameter:
public record ParamMeta( String name, ParamSource source, // ResourceMethodMeta.ParamSource — see the note below Class<?> type, @Nullable Class<?> componentType, // element type for collection/array parameters @Nullable Type genericType, // full generic type for body parameters @Nullable String defaultValue, // from @DefaultValue ParameterMetadata parameterMetadata) { … }It composes dev.vertique.core.codegen.ParameterMetadata rather than holding an eager
Annotation[]; findAnnotation(Class), hasAnnotation(Class), and annotationsLazy() all delegate
to it. A convenience constructor accepting (name, source, type, componentType, genericType, defaultValue, Annotation[]) wraps the array in ReflectiveParameterMetadata for call sites that
have a raw array. annotationsLazy() is what a JAX-RS ParamConverterProvider receives, so a
provider that inspects parameter annotations behaves identically on runtime-scanned and
codegen-generated routes.
ParamSourceis an ambiguous simple name across the REST modules.dev.vertique.rest.jaxrs.ResourceMethodMeta.ParamSourcehas 11 constants —PATH,QUERY,HEADER,COOKIE,BODY,CONTEXT,PRECONDITIONS,FORM,FILE_UPLOADS,ENTITY_PARTS,BEAN_PARAM. It is a different enum fromdev.vertique.rest.core.convert.ParamSource(5 conversion-applicable constants:PATH,QUERY,HEADER,COOKIE,FORM) and fromdev.vertique.rest.client.meta.ClientParamMeta.ParamSourceindev.vertique:vertique-rest-client(7 constants). Always qualify which one you mean.
BoundRequest
Section titled “BoundRequest”The neutral per-request binding surface. The selected RequestValidationStrategy builds one and
stashes it on the routing context; ResourceMethodInvoker reads it when extracting arguments. A
custom validation strategy is the reason this type is public.
public interface BoundRequest { String KEY_META_DATA_BOUND_REQUEST = "vertique.rest.jaxrs.boundRequest"; String KEY_RESOLVED_BODY_MAPPER = "vertique.rest.jaxrs.resolvedBodyMapper"; String KEY_ERROR_BODY_MAPPER_DECIDED = "vertique.rest.jaxrs.errorBodyMapperDecided";
Map<String, RequestValue> pathParameters(); Map<String, RequestValue> query(); Map<String, RequestValue> headers(); Map<String, RequestValue> cookies(); RequestValue body(); HttpServerRequest raw();}DefaultBoundRequest is the shared implementation every bundled strategy constructs; binding is
deliberately independent of validation, so a value binds even when a gate would have rejected it.
Declared parameter shapes drive the bound representation:
- Scalar path/query/header/cookie parameters bind as the raw transport string (first value
only for multi-maps). Conversion to the declared type runs later in
ParameterExtractor, after input policies. - Collection-declared query/header/cookie parameters (
List/Set/SortedSet/NavigableSet/Collection/T[]) bind as aJsonArrayof raw strings — every submitted value for the name, or a single-entry array for a present cookie. Element conversion and materialization also run inParameterExtractor. - Undeclared keys bind as their raw first-value string.
ExceptionMapperRegistry
Section titled “ExceptionMapperRegistry”Hierarchy-aware Throwable → jakarta.ws.rs.core.Response dispatch, assembled from
DefaultExceptionMapper plus the application’s Set<ExceptionMapper<?>>. Application mappers take
precedence over framework defaults for the same type.
public class ExceptionMapperRegistry { public ExceptionMapperRegistry(DefaultExceptionMapper defaults, Set<ExceptionMapper<?>> mappers); public Response toResponse(Throwable throwable); public <T extends Throwable> void register(Class<T> exceptionType, ExceptionMapper<T> mapper); public boolean hasSpecificMapper(Class<? extends Throwable> exceptionClass);}Lookup walks the superclass chain for the most specific registered mapper and caches the result.
hasSpecificMapper reports whether anything other than the Throwable catch-all matched; the error
pipeline uses it to decide whether to restore a Vert.x-intended status code.
DefaultExceptionMapper
Section titled “DefaultExceptionMapper”The framework’s ExceptionMapper<Throwable>, pre-configured by RestModule:
| Exception | Status | Body |
|---|---|---|
jakarta.ws.rs.WebApplicationException |
from getResponse() |
the JAX-RS Response as-is — entity passthrough when present, else a problem detail |
RestValidationException (rest-core) |
400 | ValidationProblemDetail with an errors array |
any ValidationException implementing ViolationCarrier (core.validation), including BeanValidationException |
400 | ValidationProblemDetail; violations become ValidationErrorDetail with location null |
ValidationException (core.exception) that is not a carrier |
400 | ProblemDetail |
ParamConversionException (rest-core convert) |
400 | ProblemDetail — an inbound value failed conversion to its declared type |
ParamConverterNotFoundException (rest-core convert) |
500 | ProblemDetail with the fixed detail "Internal Server Error" — no converter satisfies a declared type at request time; the exception’s diagnostic message (parameter name and target type) is logged server-side and never echoed |
IllegalArgumentException |
400 | ProblemDetail |
UnauthorizedException (core.exception) |
401 | ProblemDetail |
ForbiddenException (core.exception) |
403 | ProblemDetail |
ConflictException (core.exception) |
409 | ProblemDetail |
NotFoundException (core.exception) |
404 | ProblemDetail |
UnavailableException (core.exception) |
503 | ProblemDetail |
Throwable (catch-all) |
500 | ProblemDetail with the fixed detail "Internal Server Error" — the exception message is never echoed. Logging is deferred to ErrorPipeline (see below) so a Vert.x 4xx fallback is not recorded as an unhandled server fault |
A ViolationCarrier detail renders with detail, type and args and neither pointer nor parameter nor
location, whatever its path form: a detail’s path is a location in the value its producer validated, not in
the request, and the default mapping cannot know where that value sits. The rendering is
ValidationErrorDetail.ofViolations(carrier), which an application’s own exception mapper can call. To report
the pointer details of a carrier as body locations, a mapper that knows the validated value is the request body
(or sits at a known place in it) calls ValidationErrorDetail.ofViolations(carrier, basePointer) with ""
or the member’s pointer such as "/payload"; a JSON_POINTER detail whose joined pointer is accepted by
ViolationPathForm.accepts then gets pointer and location "body", and every other detail stays without a
location. Neither method logs. ErrorPipeline logs a carrier’s violations at DEBUG once per response, at most
100 events: for a detail with a Java path the non-empty path (a string path stops at its first bracket and has
control and format characters removed) with its type, for a pointer detail only its type, and nothing for a
detail with no path; never a pointer, a message, a key or an index.
Entries of a RestValidationException are rendered as built.
The mapper dispatches by class and checks for ViolationCarrier inside the ValidationException
handler. Overriding the ValidationException mapping changes how every carrier that is not itself
covered by a more specific registered mapper renders. BeanValidationException keeps its own explicit
mapping, whose output is identical to the carrier rendering, so a ValidationException override does not
change it; override BeanValidationException as well to change that one. A workflow payload error is
covered by the ValidationException mapping, and also by an application mapper registered for
WorkflowException or BusinessRuleException (supertypes of the payload exception), which takes over
payload errors as well.
UnauthorizedException and ForbiddenException are registered by fully-qualified name to avoid
ambiguity with jakarta.ws.rs.NotAuthorizedException and jakarta.ws.rs.ForbiddenException.
VertiqueSecurityException has no mapper of its own and falls through to the catch-all.
RestExceptionMapper
Section titled “RestExceptionMapper”The REST-layer Throwable → Throwable pre-translator that runs before the registry. It extends
dev.vertique.core.failure.FailureMapper with covariant overrides so registrations chain:
public class RestExceptionMapper extends FailureMapper { @Override public <T extends Throwable> RestExceptionMapper on(Class<T> type, FailureTranslator<T> translator); @Override public <T extends Throwable> RestExceptionMapper on(Class<T> type, ContextAwareFailureTranslator<T> translator); // Throwable translate(Throwable) inherited from FailureMapper}RestModule wires one instance from the Set<RestExceptionMapperCustomizer> multibinding.
ErrorPipeline
Section titled “ErrorPipeline”The shared failure path, used both per operation and by the router-level failure handler.
mapToResponse(RoutingContext, Throwable) returns a Future<Response>; it does not send — the
response pipeline does.
RequestInterceptor.onError sync observers, with the original cause → store the original Throwable under RequestInterceptor.ORIGINAL_ERROR_KEY → ErrorInterceptor.beforeMapping (async chain, Throwable → Throwable) → RestExceptionMapper.translate(cause) → ExceptionMapperRegistry.toResponse(translated) → Vert.x status-code fallback (only when no specific mapper matched) → ErrorInterceptor.afterMapping (async chain, Response → Response) → unhandled-exception logging (framework Throwable catch-all only; level follows the final status)The original cause stays on the routing context for the whole of error processing, so audit and
diagnostic interceptors can still see the root cause after mapping. A beforeMapping or
afterMapping handler that fails is logged at WARN and its input passes through unchanged — one bad
interceptor cannot break the error path.
Unhandled-exception logging. The framework Throwable catch-all no longer logs inside the mapper:
the catch-all has no routing context and cannot see a Vert.x 4xx status that will replace its 500.
ErrorPipeline logs once, after mapping, fallback and the afterMapping chain have settled, using the
status of the response that will be sent (an afterMapping interceptor can change it), and only when the framework catch-all actually
produced the response (not a typed default such as IllegalArgumentException, and not an application
ExceptionMapper). Final status 400–499 is logged at DEBUG (Unhandled exception mapped to client error {status}); other outcomes keep ERROR (Unhandled exception) with the stack. That keeps a
multipart part-count rejection (or any other ctx.fail(4xx, cause) decoder limit) out of ERROR
alerting while genuine unhandled server faults still page.
Vert.x status-code fallback. The router-level failure handler records the status the Vert.x layer authoritatively decided for a failure, and the error pipeline reconciles it with the mapper’s status. Two failure shapes are recorded:
- a Vert.x
HttpExceptionthat is not a validation error — the cause is unwrapped so the registry sees the original exception type; ctx.fail(<4xx>, cause)where the cause is not anHttpException— a decoder rejection from the body handler, a415from content-type validation, a401from a JWT claims validator. The cause is left as-is, so anExceptionMapperregistered for its own type still matches.
The two shapes record different ranges. An HttpException carries 400–599, because its status was
always chosen deliberately by whatever raised it — an HttpException(503, …) from a middleware is
recorded as-is. The ctx.fail(<status>, cause) shape records 400–499 only: a 5xx there is dropped,
because ctx.fail(Throwable) synthesises a 500 indistinguishable from a deliberate
ctx.fail(500, cause). Neither shape records anything below 400 — that is not an error decision.
A status outside 400–599 does not reach the client either. ctx.fail(new HttpException(200)) and
ctx.fail(200) both answer 500: the failure has no cause to map, so its status would become the
response’s own, and a problem document under 200 OK claims nothing went wrong.
When no application-contributed ExceptionMapper registered for a type more specific than
Throwable matched, that recorded status replaces the mapped one — preserving, for example, a 401
or 403 raised by Vert.x auth middleware, or the 400 Vert.x determined for a malformed request body.
An application ExceptionMapper<Throwable> is a catch-all, not a specific mapper: it still produces
the response, but the recorded status overrides it exactly as it overrides the framework’s own
catch-all. Register the mapper for the cause’s own type when it must win. Precedence, highest first:
application-contributed ExceptionMapper for a type more specific than Throwable > recorded Vert.x failure status (the ranges above) > framework default mapping (DefaultExceptionMapper) > Throwable catch-all (500)One exception: a recorded 401 never replaces a mapped 403. Whatever produced the 403 — the
framework’s own ForbiddenException mapping, or an application ExceptionMapper<Throwable> catch-all
that answered 403 for its own denial type — it outranks a recorded 401 and the response is returned
untouched, body included. A 403 is an authorization decision; answering 401 instead tells the client
to authenticate and retry, which no fresh credential can satisfy. It invites a token-refresh loop that
cannot succeed and hides the denial from access logs and SIEM rules that count 403s. The guard keys on
the mapped status, not on the exception type or on which mapper produced it, so an application
mapping its own TenantMismatchException to 403 is protected exactly as the framework’s mapping is.
This is the only guarded pair: every other recorded status supersedes the mapped one per the ordering
above.
What the override does to the body. When the Vert.x status replaces the mapped one, the
ProblemDetail is rebuilt from the new status: title is recomputed, type is reset to
about:blank, detail is dropped, and so are typed subclass fields such as
ValidationProblemDetail.errors[] and any RFC 9457 extension members — only instance carries over. Everything the superseded body held was written for a status that no
longer applies, and on the ctx.fail(4xx, cause) path the detail is an arbitrary application
exception’s message that must not reach the client. A mapped response carrying a non-ProblemDetail
entity is fail-closed the same way: replaced with a fresh ProblemDetail for the winning status and
application/problem+json, so a 500 diagnostic body cannot ride under an overriding 401. Register your
own ExceptionMapper for the cause’s type when a specific detail is required — it outranks the Vert.x
status entirely.
Equal-status detail sanitization. When the recorded status agrees with the mapped one, the status
is left alone but a ProblemDetail detail synthesized from an arbitrary exception message is still
dropped — the same disclosure rule as the override path. That closes the leak where
ctx.fail(401, new UnauthorizedException("…")) (or any other semantic type whose own mapping already
equals the Vert.x status) would otherwise publish the cause message verbatim. A
jakarta.ws.rs.WebApplicationException whose Response already carries an entity is treated as
deliberately authored client output and keeps its body; the framework’s own 415 producers
(JaxRsRouteRegistrar’s @Consumes check and ContentTypeValidationMiddleware) author a
ProblemDetail on that response before failing the context, so their content-type diagnostics still
reach the client.
No request bytes in a problem detail. The framework’s own header-parse failures
(MediaType.valueOf, CacheControl.valueOf, the link format of Link.valueOf, and the 415 of the
@Consumes gate and of a body no decoder claims) carry a fixed message that names the failure but
never the offending header value, so an oversized or control-character-laden header cannot be reflected
into a 400 or 415 body or into a log line built from the message. The 415 detail of the gate names
the declared types, which the server controls, and not the request’s Content-Type. The URI part of a
Link value is parsed by the URI builder, whose own diagnostics name the template they reject: do not
pass a request header to Link.valueOf or UriBuilder and publish the exception message.
The same rule covers ProblemDetail.instance. The request path of a failed request carries the very
segment that failed conversion or validation, and a route template (/items/{id}) is not a URI
reference, which RFC 9457 requires, so neither is used. A problem document that leaves the pipeline
without an instance gets an opaque occurrence id, urn:uuid:<random UUID>, once the
ErrorInterceptor.afterMapping chain has settled. The same id is on the log line of the failure, so
a client that reports it can be matched to the server’s record: an unhandled exception is logged at
ERROR with the id, a response a specific mapper produced at WARN (server error, no stack) or DEBUG
(client error). An instance your own mapper or interceptor sets is kept as written, including
across a status override, and a response that is not a problem document is left alone. RFC 9457
makes instance the identifier of one occurrence, so a mapper that sets it owns that identity: make
it unique per occurrence and log it yourself, because the pipeline logs the value it finds. A
deliberately fixed value (the blob routes’ uniform denial) is not findable in the logs.
Headers when the body is rebuilt. Rebuilding the body — by this override, or by the equal-status
detail sanitization — drops the headers your mapper set that describe the octets
of the body it authored: Content-Length, Content-Encoding, Content-Range, ETag, and the
digest headers (Content-Digest, Repr-Digest, Digest, Content-MD5). They would describe bytes
the client never receives. Every other header survives, including Content-Type,
Content-Language, and response-level headers such as WWW-Authenticate, Retry-After and
Allow — a WWW-Authenticate is exactly what a status overridden to 401 needs. Set a
representation header on an error response only if the body is one the framework will not touch
(a response whose status Vert.x did not override and whose ProblemDetail carries no synthesized
detail).
DefaultResponseSerializer
Section titled “DefaultResponseSerializer”The ResponseSerializer implementation RestModule binds. It encodes the entity of a Response and
writes it to the wire, returning the wire-completion future the SPI’s dual-channel contract requires.
Status and headers are already written when it runs; the serializer owns the body only.
It selects the first ResponseBodyEncoder in OrderedExtension order whose
canEncode(entityType, effectiveContentType) matches, where the effective Content-Type is read from
the already-written headers. When nothing matches it logs a warning and switches the response to 500
/ application/problem+json. A StreamingBody is piped with endOnFailure(false) and is never
buffered, so a failed pipe leaves termination to the caller observing the returned future.
Override it with a custom @Provides ResponseSerializer to emit CBOR, XML, or any other format; a
replacement must honor the same dual-channel contract, documented under ResponseSerializer in
dev.vertique:vertique-rest-core.
RouteRegistrationException
Section titled “RouteRegistrationException”Thrown at startup when route registration found violations. Extends RestConfigurationException
(rest-core) and exposes violations() — a List<RouteRegistrationViolation> of
(operationId, ViolationType, message). Security-policy inconsistencies use the separate
SecurityPolicyViolationException instead, and are thrown immediately rather than collected. The
jaxrs.security.requireExplicitPolicy opt-in adds its own collected violation type,
NO_EXPLICIT_SECURITY_POLICY (see Explicit security policy), rather
than a separate exception.
Extension Points
Section titled “Extension Points”Most REST extension points — RouterMount, MountCustomizer, Middleware, RouterLifecycleHook,
OperationHandlerContributor, the three interceptor SPIs, RequestBodyDecoder,
ResponseBodyEncoder, ResponseProducer, ResponseSerializer, RestContextResolver, and the
parameter converters — are declared in dev.vertique:vertique-rest-core and documented there. The
ones this module owns are below. RequestValidationStrategy, OperationSchemaSource, and
FileContentVerifier are declared here but documented with their reference implementations in
dev.vertique:vertique-rest-validation.
RequestValidationStrategy gains a mount-aware gateFor(op, schemas, MountMeta mount) default
overload alongside the original 2-arg form. JaxRsRouteRegistrar calls only the 3-arg form —
threading the mount’s own MountMeta (from JaxRsRouterMount.meta()) into every gate it builds — so
a strategy overriding only the 2-arg form is unaffected, while one overriding the 3-arg form (e.g. a
per-mount OpenAPI contract) receives the registering mount’s metadata for every operation.
OperationSchemaSource.schemasFor takes the operation’s effective JsonMapperProfile alongside the
descriptor — schemasFor(op, JsonMapperProfile profile). JaxRsRouteRegistrar resolves that profile
once per operation at router build and passes it with every call.
OperationSchemas#toBuilder() is Beta API: a decorating OperationSchemaSource uses it to derive a
modified copy of the schemas its delegate returned. It seeds a new Builder with the instance’s body,
its provenance, and every parameter schema.
OperationSchemas has two INTERNAL members, public only for cross-module use by framework
modules, opaque to this module, and outside its maturity promise: the typed accessor
<T> Optional<T> bodySchemaProvenance(Class<T> type), which returns the body’s provenance only when
it is an instance of type; and Builder#bodySchema(JsonObject, Object), which sets the
body together with its provenance (both arguments required, or a NullPointerException naming the
missing one). The one-argument Builder#bodySchema(JsonObject) always leaves provenance
empty, including when it replaces a body copied by toBuilder() — so a decorating
OperationSchemaSource that delegates to another source and then rebuilds the result through
toBuilder() and the one-argument form carries no provenance forward. This module never inspects the
provenance: it stores the object as given and returns it by reference.
RestExceptionMapperCustomizer
Section titled “RestExceptionMapperCustomizer”Contributes Throwable → Throwable translations to the REST error pipeline.
@FunctionalInterfacepublic interface RestExceptionMapperCustomizer extends OrderedExtension { void customize(RestExceptionMapper mapper);}Customizers are applied as an ordered fold: sorted by OrderedExtension.comparator() (phase →
priority → orderKey) and then called in sequence, so a customizer sorting later wins — its
registration overwrites an earlier one for the same exception type, and a SYSTEM_LAST customizer
applies last regardless of numeric priority.
@Provides @IntoSetstatic RestExceptionMapperCustomizer myTranslator() { return mapper -> mapper.on(MyInfrastructureException.class, ex -> new WebApplicationException("Upstream error", 502, ex));}RestModule declares the multibinding empty by default.
jakarta.ws.rs.ext.ExceptionMapper<T>
Section titled “jakarta.ws.rs.ext.ExceptionMapper<T>”Map one exception type straight to a Response. Application mappers outrank the framework defaults
for the same type.
public class ItemNotFoundMapper implements ExceptionMapper<ItemNotFoundException> { @Inject public ItemNotFoundMapper() {}
@Override public Response toResponse(ItemNotFoundException ex) { return Response.status(404) .entity(ProblemDetail.of(404, ex.getMessage())) .type("application/problem+json") .build(); }}
@Provides @IntoSetExceptionMapper<?> itemNotFoundMapper(ItemNotFoundMapper mapper) { return mapper; }Business-rule errors with their own code
Section titled “Business-rule errors with their own code”A rule that is not a constraint on a field (for example “credit limit exceeded”) is not a validation
violation. Give it its own exception and map it to a problem document that carries the rule’s code as an
extension member; do not put the code into a ValidationErrorDetail or a ViolationDetail, which locate
failures in the request and have no member for a rule identifier. ProblemDetail is built for this:
the builder’s extension(name, value) adds a sibling JSON member, and a subclass declared with Lombok’s
@SuperBuilder adds typed members (ValidationProblemDetail does exactly that for errors).
public class CreditLimitExceededException extends BusinessRuleException { private final String code;
public CreditLimitExceededException(String message, String code) { super(message); this.code = code; }
public String code() { return code; }}
public class CreditLimitExceededMapper implements ExceptionMapper<CreditLimitExceededException> { @Inject public CreditLimitExceededMapper() {}
@Override public Response toResponse(CreditLimitExceededException ex) { return Response.status(422) .entity(ProblemDetail.builder() .type("about:blank") .title("Business rule violated") .status(422) .detail(ex.getMessage()) .extension("code", ex.code()) .build()) .type("application/problem+json") .build(); }}
@Provides @IntoSetExceptionMapper<?> creditLimitMapper(CreditLimitExceededMapper mapper) { return mapper; }The response is {"type":"about:blank","title":"Business rule violated","status":422,"detail":"...","code":"RULE001", "instance":"urn:uuid:..."}; the error pipeline stamps the instance. Keep the message free of submitted
values, as for every mapper. To report several rule failures in one response, extend ProblemDetail with a
typed list member the same way ValidationProblemDetail carries errors. Throwing a
RestValidationException with type = "business" and the code in args also works, but it bends args
(documented as constraint arguments) into an identifier, so prefer the dedicated exception.
Framework decoders and encoders
Section titled “Framework decoders and encoders”Both SPIs are declared in dev.vertique:vertique-rest-core; the implementations below are what
RestModule contributes. Application implementations default to priority 0 and therefore run
before every framework default — raise the priority above the listed values to sit behind them.
RequestBodyDecoder |
Priority | Content-Type match | Target types |
|---|---|---|---|
TextRequestBodyDecoder |
1000 | text/* |
String |
BinaryRequestBodyDecoder |
1000 | application/octet-stream |
Buffer, byte[] |
FormUrlencodedRequestBodyDecoder |
1000 | application/x-www-form-urlencoded |
any POJO (not String, Buffer, byte[], JsonObject) |
JsonRequestBodyDecoder |
1100 (fallback) | absent, or containing "json" |
JsonObject → raw; String → raw; collection/array → typed elements (see note); other → JsonObject.mapTo(targetType) |
Collection and array JSON bodies resolve their element type through Jackson’s TypeFactory. A
non-static (owner-bound) inner collection body type — for example Outer<Dto>.Inner where
Inner extends ArrayList<T> — cannot be constructed without an enclosing instance, so the decoder
fails closed with ValidationException (HTTP 400) rather than materializing an ArrayList /
LinkedHashSet that is not assignable to the declared parameter. Prefer a top-level List/Set
or a static collection subtype as the body parameter type.
ResponseBodyEncoder |
Priority | Handles |
|---|---|---|
SseBodyEncoder |
999 | ReadStream<SseEvent> with text/event-stream |
BufferBodyEncoder |
1000 | Buffer |
ByteArrayBodyEncoder |
1000 | byte[] |
StringBodyEncoder |
1000 | String |
ReadStreamBodyEncoder |
1000 | ReadStream<Buffer> |
JsonBodyEncoder |
1100 (fallback) | everything else |
@Provides @IntoSetstatic RequestBodyDecoder csvDecoder() { return new CsvRequestBodyDecoder();}JAX-RS Integration
Section titled “JAX-RS Integration”Return types
Section titled “Return types”| Return type | HTTP response |
|---|---|
Future<T> |
async; serializes T with status 200 |
Future<Void> |
async; 204 No Content |
Future<Response> |
async; status, headers, and entity taken from the Response |
T |
sync; serializes with status 200 |
void |
sync; 204 No Content |
Response |
sync; status, headers, and entity taken from the Response |
ReadStream<SseEvent> |
streaming SSE; Content-Type: text/event-stream |
When no ResponseProducer is registered for the result type, Content-Type is negotiated from the
Accept header against @Produces (or ["application/json"]), and a 406 Not Acceptable problem
detail is returned when nothing matches.
Conditional-request evaluation (If-None-Match, If-Match, RFC 9110 §13.2.2) runs only when the
produced response is 2xx and carries a validator — an ETag or Last-Modified header. A non-2xx
response is not a selected representation, and a validator-less 2xx has not opted into conditional
handling; neither is evaluated. HEAD responses that pass evaluation have their entity stripped.
Parameter extraction
Section titled “Parameter extraction”Parameters are matched in this order:
| Order | Annotation / type | Source | Target types | Notes |
|---|---|---|---|---|
| 1 | @Context, or an unannotated built-in / ContextValue type |
CONTEXT |
RoutingContext, jakarta.ws.rs.core.SecurityContext, dev.vertique.security.SecurityContext, any ContextValue subtype |
Resolved through the RestContextResolver chain; never falls through to body deserialization |
| 1 | type RequestPreconditions |
PRECONDITIONS |
RequestPreconditions |
Injected by type |
| 2 | type annotated @RequestParams |
Composite | any annotated record/class | No annotation needed on the method parameter |
| 3 | @BeanParam |
Composite | any class/record | Explicit form of the above |
| 4 | @PathParam |
PATH |
any type with a resolvable converter | @DefaultValue supported; no collection shapes — a path parameter binds exactly one value (see Collection parameter shapes) |
| 4 | @QueryParam / @HeaderParam / @CookieParam |
QUERY / HEADER / COOKIE |
any type with a resolvable converter, plus List<T>/Set<T>/SortedSet<T>/NavigableSet<T>/Collection<T>/T[] of one |
@DefaultValue supported; see Collection parameter shapes |
| 5 | @FormParam |
FORM |
FileUpload, EntityPart, List<FileUpload>, List<EntityPart>, String, primitives, and List<T>/Set<T>/SortedSet<T>/NavigableSet<T>/Collection<T>/T[] of a convertible text element type |
@DefaultValue supported for text fields and collections; see Collection parameter shapes |
| 6 | unannotated List<FileUpload> |
FILE_UPLOADS |
List<FileUpload> |
All uploads on the request |
| 6 | unannotated List<EntityPart> |
ENTITY_PARTS |
List<EntityPart> |
All parts; files wrapped as VertxFileUploadEntityPart, text fields as FormFieldEntityPart |
| 7 | unannotated, any other type | BODY |
POJO, JsonObject, String, Buffer |
Decoded by the RequestBodyDecoder chain |
@RequestParams is the preferred form for composite query objects such as OffsetPageRequest and
CursorPageRequest: no per-method annotation, and the record is reusable across endpoints.
Type coercion for PATH, QUERY, HEADER, COOKIE, and text FORM values goes through the
shared dev.vertique.rest.core.convert.ParamConversionResolver, not a fixed scalar table:
- Every transport string — including a declared
Stringparameter — is converted through the resolver after input policies. The built-inStringconverter is identity; a nativeParamConverterBinding<String>replaces it. JAX-RSParamConverterProviders are fallback for types the native registry does not resolve — they never override the built-inStringentry. A converter that returnsnullfor a present value (including a@DefaultValue) fails closed withParamConversionException(400). JsonObjectkeeps an identity fast path when the binder already holds one.- Everything else — boxed and primitive numerics,
boolean,UUID,java.timetypes,BigDecimal, enums, and any other application-registered converter — is converted through the resolver. - A collection-valued parameter —
List<T>,Set<T>,SortedSet<T>,NavigableSet<T>,Collection<T>, orT[]on@QueryParam,@HeaderParam,@CookieParam, or@FormParam— coerces each submitted value individually against the declared component type; a malformed element, a null element, or a converter that returns null for a present value fails closed withParamConversionExceptionrather than inserting null or leaving the whole collection as raw strings. The message omits the submitted value. See Collection parameter shapes for the absence/default/read-only contract. - A value that fails conversion raises
ParamConversionException(400). A declared type with no resolvable converter raisesParamConverterNotFoundException(500) — a wiring gap that startup validation is meant to catch first.
Absent scalar parameters on those same sources (PATH, QUERY, HEADER, COOKIE, and text
FORM / @FormParam) follow Jakarta REST:
@DefaultValuewins — when present, the annotation value is coerced through the same resolver and bound; the request need not supply the name.- Otherwise, primitives receive their Java language defaults (
0,0L,0.0,false,'\0', …) so reflective invocation never unboxesnull. - Otherwise, reference types (including boxed numerics) receive
null.
The BoundRequest scalar path (reflective and generated resource invocation) and the text @FormParam
path share this rule so the two never diverge. Collection-shaped parameters use the separate absence
contract under Collection parameter shapes.
Collection parameter shapes
Section titled “Collection parameter shapes”@QueryParam, @HeaderParam, @CookieParam, and @FormParam additionally accept List<T>, Set<T>,
SortedSet<T>, NavigableSet<T>, Collection<T>, or T[] of a convertible element type; @PathParam
does not. Jakarta REST defines no element-collection form for @PathParam beyond List<PathSegment>,
which this module does not support, and a path value carries no standard separator for several
elements, so a path parameter binds exactly one value. Declare the multiple values as a @QueryParam
collection instead.
- Absent, no
@DefaultValue— an empty collection for the five collection interfaces;nullforT[](an array falls outside the Jakarta REST@DefaultValuecollection rule). - Absent,
@DefaultValuepresent — a single-entry collection, or single-element array, holding the converted default. - Present — every submitted value converts individually against the component type;
@DefaultValueis ignored once at least one value is present. A cookie carries one value per name, so a present collection-declared@CookieParamalways yields a single-entry collection.@FormParamalso applies its default when the request body is absent or of an unsupported media type. - Read-only. An injected collection is unmodifiable; mutation throws
UnsupportedOperationException. This includes the native@FormParam List<FileUpload>/List<EntityPart>targets and the unannotated aggregates. Arrays stay mutable — no read-only array wrapper exists. - Input policies run on each raw string element before conversion, for every source
(
@QueryParam,@HeaderParam,@CookieParam, and@FormParam), at the sameInputLocationa scalar parameter of that source would use. Policy order is therefore uniform with scalars: policy → convert → materialize. A non-string element that is already an instance of the declared component type is kept (notoString()round-trip). A null element, or a value that is neither a string nor the element type, fails closed withParamConversionException(400).@DefaultValuestrings are converted but not policy-processed. - Ordering is whatever the transport reported for repeated values — neither Vert.x nor Jakarta REST guarantees one, and the framework makes none.
- Case sensitivity follows the transport.
@HeaderParamnames match case-insensitively (header names are case-insensitive, and HTTP/2 transmits them in lower case);@PathParam,@QueryParam, and@CookieParamnames match case-sensitively. Cookie names are case-sensitive per RFC 6265, so a request cookieSessiondoes not satisfy@CookieParam("session"), and two cookies that differ only in case bind as two independent values. SortedSet<T>/NavigableSet<T>require an element type comparable to itself. They materialize as aTreeSet, which orders by natural ordering, and a parameter declaration cannot supply aComparator. This is the application’s responsibility and is not checked at startup — a non-comparable element type mounts cleanly and fails the first request carrying a value with aClassCastException(500). DeclareSet<T>,List<T>, orCollection<T>instead when the element type is not self-comparable.- One name, one declaration. A name is bound once per source and shared by every parameter declaring
it, so two
@PathParam/@QueryParam/@HeaderParam/@CookieParamparameters of one name in a method must be interchangeable. When they are not, route registration fails withDUPLICATE_PARAM_NAME_MULTIPLICITY_CONFLICT(see Startup failures) instead of mis-binding one of the two per request. Rejected: different multiplicities (one collection-shaped, one scalar); different declared types on a scalar pair (IntegerplusUUID); different element types on a collection pair (List<String>plusList<UUID>); and any other difference in binding-affecting annotations, including a different@DefaultValue. Accepted, as redundant but correct: two identical declarations, and two collection shapes over one element type (List<String>plusSet<String>), since each parameter converts its elements and materializes its own declared collection type.@FormParamis outside the rule entirely, since its values are read per parameter rather than through a shared descriptor. - Native multipart targets are
List-restricted.FileUpload/EntityPartmaterialize natively only as a scalar target orList<T>; any other collection shape of a native target fails route registration withUNSUPPORTED_MULTIPART_COLLECTION_SHAPE(see Startup failures) rather than falling through to string conversion.
Request validation (the RequestValidationStrategy gate) inspects values exactly as the transport
delivered them; the canonicalization/sanitization chain runs afterwards, during parameter extraction —
for scalars and collection elements alike. A schema pattern proves a property of the raw submission,
not of the value the resource method receives.
@FilePart uploads
Section titled “@FilePart uploads”The annotation and its grammar are declared in dev.vertique:vertique-rest-core; the enforcing gate
and its error taxonomy live in dev.vertique:vertique-rest-validation. This module’s part is
startup-time: JaxRsRouteRegistrar rejects an invalid placement, invalid allowedTypes or
maxSizeBytes, and overlapping constrained declarations with INVALID_FILE_PART_DECLARATION, and
exposes the immutable view a validation strategy consumes.
JaxRsOperationDescriptor.fileParts() is that view — an additional projection, not a replacement:
named form file parameters also remain in parameters(). The public FilePartDescriptor constructor
enforces its own invariants (null-or-non-blank part name, media-type grammar with defensive copying
and lowercase canonicalization, and a -1-or-positive size).
Server-sent events
Section titled “Server-sent events”An SSE endpoint returns ReadStream<SseEvent> from a method annotated
@Produces("text/event-stream"). Inject SseChannelFactory (declared in
dev.vertique:vertique-rest-core, bound by RestModule) and create a channel inside the method; the
framework handles buffering, wire formatting, keepalive, and connection lifecycle. Defaults come from
jaxrs.sse.
Disconnect detection uses RoutingContext.addEndHandler (multicast), not
response().closeHandler(...). The response’s endHandler / exceptionHandler / closeHandler
setters are single-slot: replacing them cuts off every routing-context end handler on that exit path,
including completion emission and RequestContextLifecycle cleanup. Application code that needs to
observe the request ending must register through addEndHandler or
RequestContextLifecycle.Handle.onClose — never through those three response setters.
The disconnect callback isolates failures: if a custom ReadStream throws while being cancelled, the
exception is logged and completion emission and lifecycle cleanup still run exactly once.
Each event is written as lines terminated by \n, followed by a blank line:
id: <id> (omitted when null)event: <event> (omitted when null)data: <data> (omitted when null)retry: <ms> (omitted when null): <comment> (omitted when null)Keepalive comments (:\n\n) are emitted at jaxrs.sse.keepAliveIntervalMs while no data events flow.
Startup validation pairs the two halves of the contract: a method returning ReadStream<SseEvent>
must declare @Produces("text/event-stream"), and a method declaring that media type must return
ReadStream<SseEvent>. Either violation is collected into RouteRegistrationException.
examples/vertique-example-sse is a complete end-to-end example covering Last-Event-ID replay,
terminal event ordering, and connection cleanup.
JAX-RS runtime support
Section titled “JAX-RS runtime support”dev.vertique.rest.jaxrs.runtime installs a minimal RuntimeDelegate, so
Response.ok(entity).build(), Response.status(404).entity(body).type("application/problem+json").build(),
UriBuilder, and Link all work with no JAX-RS implementation on the classpath.
The runtime’s MediaType, CacheControl and Link parsers (MediaType.valueOf,
CacheControl.valueOf, Link.valueOf) split on ; and , only outside quoted strings, using the
same tokenizer as HeaderElement in vertique-rest-core. Quoted parameter values are returned
unquoted and unescaped (charset="utf-8" yields utf-8), and toString() quotes values that are not
tokens. A malformed quoted string — unterminated, ending in a backslash, or followed by further
characters — is rejected with IllegalArgumentException; Response.getLinks() skips such a Link
value and Response.getMediaType() returns null. Quoted strings the runtime writes (a media type
parameter, a Content-Disposition name or filename) escape \ and ", and write each control
character other than a horizontal tab as _ rather than deleting it, so a value can never carry a
line break into a header and evil.php with U+0001 before the final p is not written as evil.php.
Two adapters are visible to resource code:
| Class | Purpose |
|---|---|
VertxFileUploadEntityPart |
EntityPart over a Vert.x FileUpload, reading the spooled temp file. getContent() is single-use. |
FormFieldEntityPart |
EntityPart over a text form field value. getContent() is single-use. |
Example resources
Section titled “Example resources”@Path("/hello")public class HelloResource { private final HelloConfig config;
@Inject public HelloResource(HelloConfig config) { this.config = config; }
@GET @Path("/{name}") @Operation(operationId = "getHello") @ApiResponse(responseCode = "200", description = "Greeting response") public Future<GreetingResponse> getHello(@PathParam("name") String name) { return Future.succeededFuture(new GreetingResponse(String.format(config.hello(), name))); }
@POST @Operation(operationId = "createItem") public Response createItem(CreateItemRequest request) { if (request.name() == null) { throw new BadRequestException("name is required"); } Item item = itemService.create(request); return Response.created(URI.create("/items/" + item.id())).entity(item).build(); }}Bean Validation is opt-in: adding ValidationModule to the component activates
beanValidator.checkParameters(...) between argument extraction and method invocation.
@ValidateWith selects the validation groups; without it the default group applies.
@Path("/users")public class UserResource {
@POST @Operation(operationId = "createUser") @ValidateWith(Create.class) public Future<User> createUser(@Valid CreateUserRequest request) { return userService.create(request); }
@GET @Path("/search") @Operation(operationId = "searchUsers") public Future<List<User>> searchUsers( @QueryParam("email") @Email String email, @QueryParam("limit") @Min(1) @Max(100) int limit) { return userService.search(email, limit); }}Violations become a 400 ValidationProblemDetail. Each ValidationErrorDetail carries an HTTP
location inferred from the parameter’s source — body, query, path, header, cookie, form,
or, for a @BeanParam, the location of the JAX-RS annotation on the violated field or record
component; anything else is null.
A violation on the request body is reported in pointer: an RFC 6901 JSON Pointer into the request
document, over the wire names the route’s body mapper reads (@JsonProperty, a naming strategy and the
route’s JSON profile all apply), when a framework binder materialized a JSON body with that mapper. The
pointer is verified against the bound body and is shorter than the violated position, never different,
wherever the mapper or the document cannot confirm a segment: it then stops at the deepest ancestor that
it can confirm, and "" is the whole document, which is also what a class-level constraint on the body
reports.
- A renamed property is reported under its wire name (
/zip_code), a list element by its position (/items/1/name,/1/namefor a list body), a nested object by its member names. - A map entry or a set member is reported as the map or set itself (
/byKey): a key the client chose never appears in an error. - A member the client sent under an alias or a case variant is reported under the spelling it sent.
- Hidden bound required members are named on omission: when the client leaves out a member that a
constraint such as
@NotBlankor@NotNullrequires, the pointer names that member (/name,/items/1/name) if its parent object is in the document and the mapper binds the member through a setter, a creator parameter or a record component. A member the mapper binds only through a field is named when the client sent it, and otherwise the pointer stops at its parent. A shape the resolver cannot read (a custom deserializer, a builder, an unwrapped or any-setter member, a wrapper type id) also stops at the holding property; a deserializer installed by a Jackson module is not detected. - A body bound by an application
RequestBodyDecoder, a body bound with a mapper other than the one the route’s wire names come from, a body of which no readable JSON document is available, a violation a validator reports without a structured path, and a body violation beyond the first 100 of a response carry neitherpointernorparameterand keeplocation: "body"; the validator’s Java path is never offered in its place, and a path given only as text is not resolved. A violation without a structured path counts toward the 100. The mappers are compared by identity, the route’s mapper being the one in force for the request. The route’s name projection is built from its mapper when the router is built, so after the process JSON mapper is re-installed under the same profile id, routes on the default profile report no pointer until the router is rebuilt. - The
errorsof a response are sorted by parameter index, then by the lexicographic text of the violation’s loggable Java path (the path with control and format characters removed, compared character by character, soitems[10]precedesitems[2]), then by constraint type, message and arrival position. The cap counts body violations in that order, so the 100 that are resolved are the same on every request with the same body.
{ "pointer": "/items/1/name", "detail": "must not be blank", "location": "body", "type": "required" }A form-urlencoded body bound to a POJO is a set of request parameters, so its violations are reported
in parameter, never in pointer, with location: "form": the form field’s name followed by the
leading [n] positions of the violation’s path. The name is the primary wire name that the mapper
which bound the body gives the first property of the path. FormUrlencodedRequestBodyDecoder binds with
the process JSON mapper whatever the route’s profile; the form branch that runs when an input object
processor is bound uses the route’s mapper. The name is resolved from the mapper alone, with no request
document to confirm it, so an entry carries neither member, by design, when the first property of the
violation’s path is one the mapper does not bind, when the mapper binds it only through a field (for
example a public field with no setter, creator parameter or record component), or when the mapper binds
the bean through a builder or another shape that is not its reported properties. A form-bound DTO whose
properties are all bound only through fields therefore never reports a parameter, and a violation on it
carries only detail, type, args and location: "form". Form entries are neither capped nor logged.
At DEBUG the mapper logs one body violation path=… pointer=… rootType=… event for each of the first 100
body violations of a response, in the sorted order: the Java path rendered without map keys, with
control and format characters removed and cut to 512 characters; the pointer, likewise stripped and
suffixed (truncated) when it stops before the end of the path, or <omitted:unbound> when no
framework binder materialized the body or no readable document of it is available,
<omitted:mismatch> for a mapper mismatch and <omitted:unstructured> for a violation a validator
reports without a structured path, which gets no pointer; and the body’s declared type. A violation
beyond the 100th logs nothing, and a form-urlencoded body logs nothing. No message, argument, value or raw validator path is
logged.
A violation on a query, path, header, cookie or form parameter is reported in parameter
and never in pointer: the declared parameter name, followed by the leading [n] run of list or
array positions at the start of the violation’s path (@QueryParam("ids") List<@Min(1) Integer>
reports ids[1]). Nothing after the first segment that is not a position is rendered, so a nested
property, a map entry or a set member adds nothing. For a @BeanParam, the violated member is the
first property of the path; the entry carries that member’s JAX-RS annotation name and location,
then the position run that follows the member (@FormParam("tag") List<@NotBlank String> tags
reports tag[0]). Without a structured path only the name is rendered, and the validator’s path text
is never echoed. A violation that cannot be tied to a parameter — a cross-parameter or return-value
constraint, a @BeanParam member without a JAX-RS annotation or with an empty annotation name, or a
parameter that is not a request location (@Context, preconditions, file uploads, entity parts) —
carries neither parameter nor pointer and no location, only detail and type.
{ "type": "about:blank", "title": "Bad Request", "status": 400, "detail": "Validation failed", "errors": [ { "parameter": "email", "detail": "must be a well-formed email address", "location": "query", "type": "email" }, { "parameter": "limit", "detail": "must be less than or equal to 100", "location": "query", "type": "max", "args": { "value": 100 } } ]}File uploads:
@Path("/uploads")@Consumes("multipart/form-data")public class UploadResource {
@POST @Path("/file") @Operation(operationId = "uploadFile") public Future<UploadResult> uploadFile( @FormParam("file") @FilePart(allowedTypes = {"image/png"}, maxSizeBytes = 5_000_000) FileUpload upload, @FormParam("description") String description) { // Move or copy the request-owned temp file before this response completes if it must persist. return processFile(upload, description); }
@POST @Path("/part") @Operation(operationId = "uploadPart") public Future<UploadResult> uploadPart(@FormParam("file") EntityPart part) throws IOException { try (InputStream in = part.getContent()) { return processStream(part.getFileName().orElse("unknown"), in); } }
@POST @Path("/all-parts") @Operation(operationId = "uploadAllParts") public Future<List<String>> uploadAllParts(List<EntityPart> parts) { return Future.succeededFuture(parts.stream().map(EntityPart::getName).toList()); }}Text and binary bodies:
@Path("/data")public class DataResource {
@POST @Path("/text") @Consumes("text/plain") @Produces("text/plain") @Operation(operationId = "uploadText") public String uploadText(String body) { return "received: " + body; }
@POST @Path("/binary") @Consumes("application/octet-stream") @Operation(operationId = "uploadBinary") public Future<Void> uploadBinary(Buffer body) { return store(body); }}@RestApplication
Section titled “@RestApplication”The annotation itself lives in dev.vertique:vertique-rest-core (package
dev.vertique.rest.core.application, Beta); this module composes and mounts the declarations.
@RestApplication on an interface groups resources into an independently mounted API, instead of the
manual multi-API recipe under JaxRsRouterMount above. Composition consumes only
native @RestApplication registrations; a Jakarta jakarta.ws.rs.core.Application subclass is never
registered (see A leftover Application subclass below). With no
@RestApplication declared, RestModule keeps the default mount described there unchanged.
Declaring applications
Section titled “Declaring applications”@RestApplication(name = "public", path = "/api/public", resources = {CatalogResource.class, CheckoutResource.class})public interface PublicApi {}
@RestApplication(name = "mgmt", path = "/api/mgmt", resources = {ManagementResource.class})public interface ManagementApi {}Declaring these two interfaces mounts CatalogResource and CheckoutResource under /api/public/*,
and ManagementResource under /api/mgmt/*; no default mount is created. @RestApplication never
annotates a class, enum, record, or annotation type, and the declaring interface is never
instantiated. vertique-codegen-jaxrs’s annotation processor validates each declaration and emits one
GeneratedRestApplicationRegistration per interface (name, path, resources, discover,
openapiPath, and the evaluated active flag — see
Generated-code contract types below); this reference describes the
declared behavior the generated registration produces, not the processor itself.
A sole application may discover its membership instead of listing it:
@RestApplication(name = "api", path = "/api", discover = true)public interface DiscoveryApi {}The RestApplications view
Section titled “The RestApplications view”public final class RestApplications { public Optional<Entry> byName(String name); public List<Entry> all(); // every declared application, active or not, by name public record Entry(String name, Class<?> declaringType, boolean active, String mountPath, // as registered, with "/*" @Nullable String effectiveOpenapiPath, ContractOrigin contractOrigin) {} public enum ContractOrigin { CONFIGURATION, ANNOTATION, GLOBAL }}dev.vertique.rest.jaxrs.application.RestApplications is an INTERNAL, @Singleton view built once
per component from Set<GeneratedRestApplicationRegistration> and the parsed
jaxrs.applications configuration — once even with zero
registrations, since RestModule.jaxRsRouterMount requests the view on both its zero-declaration and
explicit-mode branches. It is public only so the composer, the mount composition validator, and
sibling framework modules — the OpenAPI documentation module and the openapi-contract validation
module’s contract-load check — can read it; it is not an application contract and stays outside the
maturity promise. byName looks an application up by name; all()
lists every declared application, active or not, ordered by name so its order never depends on
registration or configuration order.
Building the view runs these checks, before any resource resolves, aggregated into one
RestConfigurationException under Invalid JAX-RS application composition::
- a name declared by more than one registration, active or not, naming the name and every declaring interface;
- a registration named the reserved
noneornull— the factory already refuses these for a generated registration; this defends a hand-built one; - a declaring interface whose runtime
@RestApplication.name()differs from its registration’s name, or that carries no@RestApplicationat runtime, naming both names or the missing annotation; - a
discover = trueregistration that is not the sole declared registration, whether either registration is active or not, naming every declared registration; - a configured
jaxrs.applications.<name>that matches no declared registration, active or not, naming the configuration path —jaxrs.applications.<name>— and never its value. With zero registrations, any configured name fails this way.
Contract location
Section titled “Contract location”Each entry’s effectiveOpenapiPath and contractOrigin resolve by precedence: a configured
jaxrs.applications.<name>.openapiPath when present (CONFIGURATION; a blank value already fails
startup — see Per-application configuration); else the
declaration’s own non-empty @RestApplication.openapiPath (ANNOTATION); else the global
jaxrs.openapiPath (GLOBAL, which may itself be null). The validation strategy stays the global
jaxrs.validationStrategy regardless of which setting supplied the location.
When the optional ApiDocsModuleInstalled marker is absent (the OpenAPI documentation module is not
included in this component), the view logs one INFO line per active registration whose declaring
interface carries an annotation named dev.vertique.rest.openapi.docs.ApiDocs, naming the application
and stating that no documentation route is published for it. The annotation is
found by its fully qualified name, not a compile-time dependency, so rest-jaxrs never depends on the
docs module; without the docs artifact on the classpath, reflection drops the annotation and nothing
is logged either way. An inactive registration is never logged.
Declaration annotations
Section titled “Declaration annotations”A declaring interface may carry only @RestApplication, the annotation named
dev.vertique.rest.openapi.docs.ApiDocs, @OpenAPIDefinition with every element other than info at
its default, and types in java.lang or java.lang.annotation; the source-retained
@ConditionalOnProperty and @NoAutoWire are permitted too but are enforced on a superinterface by
the compile-time processor only, since reflection cannot see a SOURCE-retained annotation. On a
superinterface, only java.lang and java.lang.annotation types are allowed — one of the three
runtime-retained honored-only annotations above (@RestApplication, @ApiDocs, @OpenAPIDefinition)
found there is refused as “honored only on the declaring interface”; any other disallowed annotation
is refused the same way a violation on the declaring interface is. This is the same compile-time rule,
enforced again at startup by reflection over the declaring interface and every interface it extends,
transitively — so a registration the processor did not produce still fails startup. Each violation
names the application (its name and declaring interface), the offending annotation, and the type that
carries it.
Membership
Section titled “Membership”Each declared application’s membership is exactly one of two forms:
- Discovery membership. A sole
discover = trueregistration selects every enabled generated resource and every manual@JaxRsResourcesinstance; allowed only when it is the sole declared registration, whether or not any other declared registration is currently active. - Explicit membership.
resources()lists classes, matched one by one against the generated resource catalog and the manual contributions: each listed class must be a concrete resource with an effective@Pathand must resolve to exactly one generated entry or one manual@JaxRsResourcesinstance of that same class; zero or more than one match fails startup, naming the application and the offending class.
A listed class whose generated entry is currently disabled by its @ConditionalOnProperty condition
is excluded from the mount silently, without being instantiated; when every listed class is disabled
this way, the application still mounts, with no resources and no fallback to the default mount. A
manual @JaxRsResources instance matches a listed class when it is exactly that class, or a direct
subclass that declares no runtime-retained annotation on itself, its methods, or their parameters, and
adds no interface beyond what the listed class already implements — the shape the framework’s own AOP
proxy has. Any other subclass instance matches nothing and is reported by name, naming the subclass
and asking for it to be listed explicitly instead. A resolved generated resource is checked against
the same rule before it is mounted, so a Dagger-substituted instance that adds routes or annotations
fails startup the same way. Resources within one mount are ordered by fully qualified class name,
regardless of resources()’s own order.
Lifecycle and evaluation
Section titled “Lifecycle and evaluation”The declaring interface is never constructed: the composer reads the view for identity, activity,
mount path, and contract location, and the registration for resources() and discover(). Each
declared, active application is evaluated once per HttpVerticle composition — never once for the
whole process. Every resolution of Set<RouterMount> composes again: a component accessor that
returns Set<RouterMount> re-evaluates every active application and every selected unscoped resource
and repeats the informational and warning lines each composition logs — do not resolve that set
outside the verticle. An evaluation failure fails the deployment before the server starts listening,
naming the application and its declaring interface.
Threading and re-entry
Section titled “Threading and re-entry”Composition, including the view’s own checks, runs during mount composition, possibly on a Vert.x
event-loop thread, and must not block. No manually contributed @JaxRsResources resource or generated
resource catalog entry may depend on Set<RouterMount> of the same component — in zero-declaration
mode as well as explicit mode. Doing so re-enters that component’s own mount composition while it is
still in progress, and fails startup with a named RestConfigurationException instead of recursing —
stating that a manually contributed resource or catalog entry re-entered composition. Composing a
different component’s Set<RouterMount> from inside a resource is unaffected.
Framework and library modules never declare an application
Section titled “Framework and library modules never declare an application”A framework or library module contributes resources through @Provides @IntoSet @JaxRsResources, the
same seam a sibling framework module uses, but never declares @RestApplication. Declaring one is the
deploying application’s decision alone, because a single declaration switches the whole module into
explicit mode for every consumer.
Explicit mode
Section titled “Explicit mode”Once any application is declared — even when every declared application is currently inactive — explicit mode replaces the default mount for the whole module:
- No default mount is created at
jaxrs.basePath, including when every declared application is inactive. jaxrs.basePathis not applied to any application mount; a non-default value logs one warning that it is unused.- A manual
@JaxRsResourcescontribution — including one contributed by a sibling framework module — is routed only when an active application selects it. - With no active application, startup logs one warning naming the enabled generated resources and
stating that manual
@JaxRsResourcescontributions were not resolved. - With at least one active application, startup logs one warning — only when the list is non-empty — naming every enabled generated resource and manual instance that no active application selected, worded “not selected by any Application”.
- Startup also logs one informational line listing every declared registration (its name, declaring interface, path, and whether it is currently active), and one informational line per application mount naming its resource classes in mount order.
- Removing every declared application restores the default mount described under
JaxRsRouterMountabove.
Generated-code contract types
Section titled “Generated-code contract types”dev.vertique.rest.jaxrs.runtime.GeneratedRestApplicationRegistration is an INTERNAL generated-code
contract type the annotation processor emits; it is not for hand-written use.
GeneratedRestApplicationRegistration.of(...) re-checks the name grammar and the reserved names, the
normalized path form, and the exactly-one-of resources/discover membership form, throwing
IllegalArgumentException naming the declaring interface and the violated rule — a fail-closed
backstop for a registration the processor did not produce. This type evolves additively: a new
attribute arrives as a new of overload; an existing factory signature stays for at least one further
minor release line, and the factory’s re-checks may only loosen, never tighten, across releases.
At startup, before any resource resolves, every declared registration’s declaring interface and its
superinterfaces are re-checked by reflection against the same declaration annotation
allow list the annotation processor enforces at compile time — no
superclass is walked, because the declaring interface is an interface, never constructed — whether or
not the registration is active and whether it was generated or hand-written. Each violation applies
the same rule as the compile-time check, with its own message; when one or more are found, startup fails with a
RestConfigurationException that lists every violation found across all registrations, one per line,
under Invalid JAX-RS application composition:, together with any other composition problem found at
that point.
Mount conflicts
Section titled “Mount conflicts”Once any application is declared, the checks below reject overlapping or colliding mounts before any route installs:
- Between active applications. Two active applications whose mount paths overlap — their prefixes
(each mount path without its terminal
*, so it keeps its trailing/) are equal, or one starts with the other — fail startup before any resource resolves, naming both applications (name and declaring interface) and both paths. An application declared atpath = "/"therefore conflicts with every other active application, because/is a prefix of everything. - Between an application and a hand-built JAX-RS mount. An application mount whose path overlaps a
hand-built
JaxRsRouterMount’s path, in either direction, fails startup before any mount router is created, naming the application (name and declaring interface) and both paths. A hand-built JAX-RS mount whose path is a router pattern — containing:,{, or}— conflicts with every application mount regardless of any literal prefix relation, because the segment it matches is not known until request time. - Between application mounts merged from more than one composition. The same path-conflict check
also compares every pair of application mounts in a
Set<RouterMount>merged from more than oneHttpVerticlecomposition — a shape no single composition’s own resolution ever sees. - Repeated application name across merged mounts. A non-null application name carried by more than one application mount in the merged list is refused, naming the name and both mount paths. A single composition’s own view already refuses a repeated name before any mount is built; this exists only for mounts merged from more than one composition.
- Unaffected. A non-JAX-RS mount, and a pair of JAX-RS mounts that include no application, keep the
warning-only overlap detection
HttpVerticlealready performs for every mount.
Only the Dagger-built HttpVerticle hosts application mounts. Its composition validators run the
checks above, the cross-mount operationId refusal and, where it applies, the contract-location parse
below (see Startup failures), and mark each application mount instance validated
only once every check passes for the whole composition; an HttpVerticle built with the public
five-argument constructor, or a subclass that runs no composition validator, refuses to create an
application mount’s router at all, naming the application (name and declaring interface) and stating
that HttpVerticle must come from Dagger so its composition validators run first.
Contract-location parse, under a contract-driven strategy. RequestValidationStrategy gains
resolvesOperationsFromMountContract() (default false), documented as “this strategy looks each
operation up by operationId in the contract of the mount that registers it”; the opt-in
openapi-contract strategy (vertique-rest-openapi-validation) reports true. When the registered
strategy selected by jaxrs.validationStrategy reports it, the mount composition validator also
normalizes every JAX-RS mount’s non-null contract location (Path.of(value).normalize()) and reports
one it cannot parse as a violation, naming the mount — an application by its name and the setting its
location came from (jaxrs.applications.<name>.openapiPath, the @RestApplication annotation’s
openapiPath, or jaxrs.openapiPath), a hand-built mount by its path — and never the raw,
unparseable value. This parse runs under the same gate as the operationId refusal below, and never
when no registered strategy carries the configured jaxrs.validationStrategy id (createRouter’s own
fail-fast selection reports that separately).
Explicit security policy
Section titled “Explicit security policy”JaxRsRouteRegistrar classifies every operation’s declared security posture at registration time,
from the same effective SecurityPolicy, annotation-sourced security requirement sets, and
resolved @RequiresAction that request-time enforcement already uses — the classification runs no
second policy resolution.
An operation restricts callers when any of these holds:
- its effective policy is
DenyAll,AuthenticatedOnly, orConstrained— theSecurityPolicyvariants@DenyAll,@RolesAllowed, and@Authorized(dev.vertique:vertique-security-core) resolve to; an@Authorizedwith no scopes resolves toAuthenticatedOnly(authentication only), and one with scopes, or@RolesAllowed, resolves toConstrained. A@RequiresPolicyreference is expanded to those same variants before this classification. The scanner collects the reference from the resource hierarchy, including an interface the consumer adds. A generated companion’s method-annotation list is that same collection, notClass.getMethod, so a generic override and a same-package non-public override keep the action. An unknown action remainsREQUIRES_ACTION_INVALID, and restrictive security without an auth module remainsSECURITY_ANNOTATIONS_WITHOUT_AUTH_MODULE; - it declares one or more
@SecurityRequirements and none of its alternatives is anonymous (an empty requirement, which annotations cannot currently express); a scopeless@SecurityRequirementstill restricts callers; - it has a resolved
@RequiresAction.
An operation is the public declaration when it does not restrict callers and its effective
policy is PermitAll (@PermitAll). A restricting declaration always takes precedence over
@PermitAll on the same operation. Any other operation is implicit — no explicit security
policy at all.
Warning, application mounts only. An application mount whose registration left one or more
operations implicit logs exactly one WARN per composition, naming the application and every implicit
operation — sorted by full path, then method, then operation id — in the shape Application '<name>' (<declaring interface>) at '<mount path>' has operations with no explicit security policy: <METHOD> <full path> (operationId '<id>'), …. Each operation’s full path is the mount prefix (the
mount path without its trailing *) joined with the operation’s own path — for example
GET /api/mgmt/status. A mount with no implicit operation logs nothing, and a hand-built
(non-application, including the legacy default) JaxRsRouterMount never logs this warning, however
many of its operations are implicit.
Opt-in: jaxrs.security.requireExplicitPolicy. When this jaxrs key (declared in
dev.vertique:vertique-rest-core, default false) is true, every implicit operation on any
JaxRsRouterMount — application or hand-built — fails startup with a NO_EXPLICIT_SECURITY_POLICY
route violation instead of only being warned about, and the warning above is never logged in that
case (see Startup failures).
Without the opt-in, an implicit operation on an application mount still starts after the warning — the application deploys, and request-time enforcement is unchanged either way. This check only decides what is warned about or what fails startup at registration; it never changes how a matched request is authenticated or authorized.
A leftover Application subclass
Section titled “A leftover Application subclass”A jakarta.ws.rs.core.Application subclass compiles — with exactly one warning per class, unless the
class carries @NoAutoWire — but is never registered: it has no runtime effect, and no route ever
answers under its @ApplicationPath. The processor’s warning states both cases of where the class’s
resources are actually served: with no @RestApplication declared in the component, they are served
on the legacy default mount at jaxrs.basePath; otherwise they are served only where a
@RestApplication lists them. To migrate one, declare an @RestApplication interface with the same
path and resource list (or discover = true), then delete the subclass or annotate it @NoAutoWire
to silence the warning while migrating incrementally. A migrated root application (path = "/") keeps
conflicting with every other active application and every hand-built JAX-RS mount, exactly as
described under Mount conflicts above, because every other prefix overlaps /.
Configuration
Section titled “Configuration”http and jaxrs are declared and parsed in dev.vertique:vertique-rest-core, with one exception:
this module parses the jaxrs.applications section itself (see
Per-application configuration). The keys it acts on are
jaxrs.basePath, jaxrs.openapiPath, jaxrs.sse.*, jaxrs.validationStrategy,
jaxrs.security.requireExplicitPolicy (see Explicit security policy),
jaxrs.applications.<name>.openapiPath, and the JSON profile keys below.
JSON profile selection
Section titled “JSON profile selection”The ObjectMapper used for a resource method is resolved once at router-build time, highest first:
@JsonProfile("id")on the resource method;@JsonProfile("id")on the resource class;jaxrs.jsonProfilewhen non-blank (per-boundary default);json.jsonProfilewhen non-blank (global default);vertique— the framework’s opinionated profile, and the floor every managed edge shares.
Every id resolved above, the reserved system included, is resolved to its mapper through the
JsonMapperProfileRegistry. An unknown profile id fails at startup, not on the first request.
JaxRsDefaultProfileValidator additionally resolves jaxrs.jsonProfile during the VALIDATE phase,
so a bad boundary default fails even when no resource method would have used it.
The keys this resolution reads:
| Key | Floor | Meaning |
|---|---|---|
jaxrs.jsonProfile |
none (falls through when blank) | per-boundary default for every route on this mount |
json.jsonProfile |
vertique |
the global default for managed edges — REST bodies, rest-client, Kafka JSON, MCP |
json.systemProfile |
system |
the profile installed as the process JSON codec, which every Json.encode, Json.decodeValue, and JsonObject operation runs on. Not a REST key, but it decides which routes take the fast path below |
The “no override” fast path is an identity rule. After resolving the effective id to a mapper, the
resolver returns “no override” — leaving the route on the plain Vert.x body and Json.encode paths —
iff that mapper is the very instance the process JSON codec runs on. It is never keyed on the
profile id. Under the defaults (json.jsonProfile: vertique, json.systemProfile: system) that means
a route explicitly selecting system takes the fast path and every other route binds and renders
through its own profile mapper. Change json.systemProfile and the fast path follows it: an explicit
system route under json.systemProfile: vertique resolves the system mapper, which is no longer
the process codec’s, so the route binds through system rather than silently through vertique.
The comparison is captured once per route, when the router is built (the EDGE startup phase,
after CONFIGURE installs the process mapper). Two consequences follow. A custom
JsonMapperProfileRegistry that returns a fresh ObjectMapper per call never matches, so its routes
never take the fast path — the built-in registry hands out one stable instance per profile. And
swapping the process codec’s mapper after routers are built (a second application booting in the same
JVM with the same system profile id) does not retroactively change routes already decided: routes
that resolved to the process codec (the null fast path) follow the swap at request time, while routes
that captured a registry mapper keep binding with it — so after such a swap two routes of one mount
can bind with different mappers.
Rollback. Setting json.jsonProfile: system puts every unannotated route back on the baseline
recipe — no NON_NULL omission, no BigDecimal floats, no enum-default leniency, and JSON comments
accepted again (the baseline inherits Vert.x’s comment tolerance) — while keeping the Optional and
java.time support the baseline gained. It is the single-key way to undo the
opinionated default for REST without touching resource code.
@Path("/orders")@JsonProfile("strict") // class-level default for every methodpublic class OrderResource {
@POST @Operation(operationId = "createOrder") public Future<Order> createOrder(CreateOrderRequest request) { return orderService.create(request); }
@PUT @Path("/{id}") @JsonProfile("lenient") // method-level override wins @Operation(operationId = "updateOrder") public Future<Order> updateOrder(@PathParam("id") String id, UpdateOrderRequest request) { return orderService.update(id, request); }}{ "jaxrs": { "jsonProfile": "strict" } }Requests. Unless the route took the fast path above, the resolved mapper performs the first
parse of a JSON-content-type body — raw bytes to JsonObject, JsonArray, or a scalar — under that
mapper’s parser features (STRICT_DUPLICATE_DETECTION, FAIL_ON_TRAILING_TOKENS, and so on),
uniformly for object, array, and scalar bodies. It then performs POJO/collection materialization from
the parsed value. A route on the fast path is parsed by the process codec, which is the same mapper
its profile resolved to. Non-JSON content types take the Vert.x path.
A body the route’s binding mapper rejects — on the profiled path and on the fast path alike — becomes a
value-free HTTP 400 with the detail "Request body rejected by JSON profile", routed through the
standard problem-detail pipeline. The
original rejection is kept as the exception cause for server-side diagnosis and is never
serialized to the client.
Responses. JsonBodyEncoder reads the same resolved mapper, so a method’s response uses exactly
the profile its request used. Errors raised inside a matched method use the method’s mapper; errors
raised before a method match — a pre-routing 404, a schema rejection — use the boundary-plus-global
default resolved inline at router-build time.
Fail-open for error bodies. If a profile mapper throws while serializing an error or
ProblemDetail body, serialization falls back to the process JSON codec with the mapped status code
and the application/problem+json media type preserved, and logs a WARN. A profile-mapper failure on a
success entity still surfaces as HTTP 500 — the fail-open is scoped to the error pipeline.
Where the request-validation gate and the body binder disagree
Section titled “Where the request-validation gate and the body binder disagree”The request-validation gate is not profile-aware: web-validation synthesizes schemas from the
declared types, openapi-contract validates against the mount contract, and neither knows which
profile binds the body. For each behavioral difference between the reserved profiles, the safe
direction is “the gate is at least as strict as the binder”. Observed:
| Difference | Gate | vertique binder |
Direction |
|---|---|---|---|
| Unknown enum string | rejected by the schema enum before binding |
@JsonEnumDefaultValue when declared, else rejected |
gate stricter — safe |
Optional<T> property |
validated as the plain component type (optionals are flattened) | binds the component, absent stays empty | equivalent |
BigDecimal floats |
format-level; the gate is agnostic | USE_BIG_DECIMAL_FOR_FLOATS |
equivalent |
java.time and Optional bodies |
accepted as before | now bind instead of failing | strictly a widening of what binds — never a bypass |
null omission |
response-side only; no gate applies | omitted (NON_NULL) |
not gated |
JSON with /* */ or // comments |
accepted (the gate parses through JsonObject) |
rejected | gate lenient, binder strict — safe; both accept under an explicit system |
jaxrs.validationStrategy: none has no gate; the binder is authoritative. Every one of the rows
above collapses to whatever the effective profile does, including enum leniency: a route bound by
vertique (or any enum-lenient profile) accepts an unknown enum string as the type’s
@JsonEnumDefaultValue with nothing in front of it to reject the value first. Select system, a
vertique-strict, or a custom profile for a boundary that must reject unknown enum strings without a
gate.
Per-application configuration
Section titled “Per-application configuration”jaxrs.applications is a keyed object: each key is an application’s name, and its value is that
application’s settings object. The section sits under jaxrs, but this module parses it at startup;
vertique-rest-core’s jaxrs parse (JaxRsConfig) does not declare it and ignores it.
| Key | Default | Constraint / notes |
|---|---|---|
jaxrs.applications.<name>.openapiPath |
the declaration’s own @RestApplication.openapiPath, then jaxrs.openapiPath |
string; overrides both when set. An absent key or an explicit null falls back to that precedence; an empty or whitespace-only value fails startup |
{ "jaxrs": { "applications": { "orders": { "openapiPath": "orders-openapi.yaml" }, "admin": {} } }}The section is strict:
- The section name is matched case-sensitively. A
jaxrskey that equalsapplicationsignoring case but is spelled differently —Applications,APPLICATIONS— fails startup, whatever its value, even when no exactapplicationskey is present. A key that is not a case variant, such as the singularapplication, is an unknownjaxrskey and stays ignored. - An absent
jaxrs.applicationsand an empty object{}both configure no per-application settings. Any other value that is not a JSON object — a string, number, boolean, array, ornull— fails startup. - Every entry must be a JSON object;
{}is a valid entry with no settings. An entry whose value is anything else,nullincluded, fails startup. openapiPathis the only key an entry accepts, matched case-sensitively. Any other key fails startup, includingname(the entry key already is the name) and case variants such asOpenapiPath.
That strictness is a deliberate narrowing of the “unknown keys are ignored” rule
vertique-rest-core applies to jaxrs, limited to the reserved name applications. Each of those
failures, and a blank openapiPath, raises a ConfigurationException naming the offending
configuration paths (for a miscased section name, the offending jaxrs keys), sorted, never their
values. The section name is checked first, then shapes and keys, all before any openapiPath
value is parsed, so blank values are reported only when no section-name, shape, or key problem
remains (see Startup failures).
Failures, Constraints, and Common Mistakes
Section titled “Failures, Constraints, and Common Mistakes”Startup failures
Section titled “Startup failures”RouteRegistrationException carries every RouteRegistrationViolation found across all resources:
ViolationType |
Meaning |
|---|---|
DUPLICATE_OPERATION_ID |
two methods resolve to the same operationId within one mount |
MULTIPLE_BODY_PARAMS |
more than one unannotated body parameter |
FORM_AND_BODY_CONFLICT |
@FormParam or file-upload parameters mixed with a body parameter |
INVALID_FILE_PART_DECLARATION |
@FilePart on an unsupported type, invalid allowedTypes/maxSizeBytes, or overlapping constrained declarations |
UNSUPPORTED_MULTIPART_COLLECTION_SHAPE |
a @FormParam collection parameter’s element type is a native multipart target (FileUpload/EntityPart) declared in a shape other than List |
DUPLICATE_PARAM_NAME_MULTIPLICITY_CONFLICT |
two parameters bind the same name from the same source but cannot share one declaration — incompatible multiplicities (one collection-shaped, one scalar), different scalar types, different collection element types, or different binding-affecting annotations (including @DefaultValue); scoped to @PathParam/@QueryParam/@HeaderParam/@CookieParam |
SECURITY_ANNOTATIONS_WITHOUT_AUTH_MODULE |
restrictive security annotations present but AuthModule absent |
CONTEXT_PARAM_CONFLICT |
a @Context parameter also carries a JAX-RS value-binding annotation — the two are mutually exclusive |
UNSUPPORTED_JAXRS_CONTEXT_TYPE |
a @Context parameter declares a reserved JAX-RS type that is not supported (e.g. UriInfo, HttpHeaders); fails fast instead of injecting null |
NON_INJECTABLE_CONTEXT_TYPE |
a @Context parameter’s type is neither a built-in injectable nor a ContextValue subtype |
REQUIRES_ACTION_INVALID |
a @RequiresAction declaration is malformed |
REQUIRES_ACTION_POLICY_CONFLICT |
a @RequiresAction declaration conflicts with the operation’s resolved security policy |
UNRESOLVABLE_PARAM_CONVERTER |
a path/query/header/cookie/form parameter type — or a collection’s element type, or a convertible @BeanParam field — has no converter resolvable by the ParamConversionResolver chain |
NO_EXPLICIT_SECURITY_POLICY |
jaxrs.security.requireExplicitPolicy is true and the operation is implicit (see Explicit security policy); message <METHOD> <full path> has no explicit security policy, which jaxrs.security.requireExplicitPolicy requires; fix by annotating the operation with @PermitAll or a restricting declaration |
Building the RestApplications view (see The RestApplications
view above) and the composer’s step-1 and membership checks (see
Membership above) each throw RestConfigurationException, aggregated under
Invalid JAX-RS application composition:, before any resource resolves — the view’s checks run once
per component, even with zero registrations. RestModule’s @Singleton RestApplications provider
runs its configuration parse (parseJaxRsApplications, see Per-application
configuration below) first, before the view’s own checks; that
parse’s ConfigurationException propagates unwrapped and alone, never combined into the view’s or
the composer’s aggregated RestConfigurationException.
Once one or more @RestApplication registrations are declared — even when none is active — the
Dagger-built HttpVerticle’s composition validator (JaxRsApplicationMountValidator) additionally
reports these as violation strings, surfaced through HttpVerticle’s own aggregated
IllegalStateException("Invalid mount configuration: …") rather than thrown directly by the
validator:
- a duplicate operationId across mounts: two operations on different
JaxRsRouterMounts that share an operationId fail startup unless they are the same operation — the same resource class (an annotation-free AOP proxy counts as its base class), method name, and parameter types — naming both operations and both mounts; this global, cross-mount refusal is kept until a later change scopes it per mount; - a non-null application name carried by more than one application mount in a
Set<RouterMount>merged from more than one composition, naming the name and both mount paths; - under a strategy reporting
resolvesOperationsFromMountContract(), a JAX-RS mount whose contract location cannot be parsed, naming the mount and the setting its location came from, never the value (see Mount conflicts above).
The per-mount DUPLICATE_OPERATION_ID row above is unaffected — it keeps comparing only within one
mount, and with no application declared it remains the only operationId check that runs. An
HttpVerticle built with the public five-argument constructor runs none of this validator’s checks
and refuses to create any application mount’s router.
Parsing jaxrs.applications (see Per-application configuration)
raises ConfigurationException. The section-name, shape, key, and blank-value checks use the four
messages below; <keys> lists every offending jaxrs key and <paths> every offending
configuration path, each single-quoted, sorted, and comma-separated. An entry that passes those
checks but cannot be bound — an openapiPath that is a JSON object or array, or a blank entry
key — fails instead with the configuration parser’s own
message, which does not name the full configuration path. No message contains a configured value.
| Condition | Message |
|---|---|
A jaxrs key equals applications ignoring case but is spelled differently |
Miscased applications keys under 'jaxrs': <keys>; per-application settings belong under 'jaxrs.applications' |
jaxrs.applications is present but not a JSON object (null included) |
'jaxrs.applications' must be a JSON object |
An entry is not a JSON object (null included), or an entry carries a key other than openapiPath |
Invalid entries or keys under 'jaxrs.applications': <paths>; each entry must be a JSON object whose only key is 'openapiPath' |
A configured openapiPath is empty or only whitespace |
Blank values under 'jaxrs.applications': <paths>; set 'openapiPath' to a non-blank location or omit it |
In the third message a path is jaxrs.applications.<name> for an entry that is not an object and
jaxrs.applications.<name>.<key> for a rejected key; in the fourth it is
jaxrs.applications.<name>.openapiPath.
SecurityPolicyViolationException is thrown immediately when a SecurityPolicyValidator is bound and
finds a violation, rather than being collected. JsonProfileConfigurationException is thrown at
router-build time for an unknown profile id.
Route registration also gates declared input processing, raising ConfigurationException with one
aggregated message naming every offending route:
| Condition | When it is checked |
|---|---|
A route declares a canonicalizer or sanitizer chain — on the route, on a processed parameter, or inside a processed parameter’s type graph — while no InputObjectProcessor is bound |
only when the binding is absent |
A byte[] or io.vertx.core.buffer.Buffer body parameter carries a declared chain |
always, bound engine or not |
Both are declarations that provably could not run, and there is no opt-out flag: “declared but not
running” is not a second legitimate mode. The unbound-engine check ignores parameters whose source
never reaches the engine (@Context, RequestPreconditions, raw multipart), so it never asks for a
module that would change nothing. The binary-body check is independent of the binding because no
Dagger graph can make a chain act on opaque bytes; its message points at FileContentVerifier as the
control that does inspect binary content, while stating that FileContentVerifier covers multipart
FileUpload parts rather than a raw binary body parameter — a pointer, not a drop-in replacement.
Remove the declaration, or add @SkipCanonicalization / @SkipSanitization to the binary parameter.
When an InputObjectProcessor is bound, route registration also composes each route’s body wire-name
projection (JacksonFieldNameResolver, from dev.vertique:vertique-json) against that route’s
resolved body mapper. The engine decides which types get composed, not the registrar: registration
hands each body parameter’s declared type to InputObjectProcessor.precomputeFieldNameResolution,
which prepares the resolver for every owner type its own descent may consult for that body. The
postcondition is that no statically knowable owner is left to introspect on the request path — the
vertique-input-processing reference documents the owner set and its bounds in full.
Two startup failures follow, both of which previously surfaced per request:
- A type in that set whose projection cannot be composed — two properties claiming one wire name, or
two properties claiming one
@JsonAlias— fails router build withConfigurationExceptionnaming the type and the contested name, including when it is a nested DTO rather than the body type itself. - A reachable type declaring conflicting policy annotations (on a method, a parameter, or a
bean-param field) fails router build with
IllegalStateException, because preparing the owner set resolves that type’s policy metadata.
Composing at registration is what makes each a startup failure rather than a 500 on every request that touches the type, and it keeps Jackson bean introspection off the event loop.
Request-time failures
Section titled “Request-time failures”Any exception a resource method throws or fails its Future with enters the error pipeline and is
mapped by ExceptionMapperRegistry. Unmapped exceptions become a 500 problem detail whose detail is
the fixed string "Internal Server Error" — never the exception message.
A failure that surfaces after the response head is on the wire cannot change the status. When a
streamed or buffered body is truncated, the pipeline closes the response in the way that frames the
truncation honestly: a chunked response is ended normally, while a non-chunked response whose declared
Content-Length does not match the bytes written is reset (RST_STREAM on HTTP/2, connection close
on HTTP/1.1) rather than ended, so a peer observes an incomplete transfer instead of a well-framed
lie. Clients must therefore be prepared for a reset mid-response and must not treat a 200 status line
as proof of a complete body.
Common mistakes
Section titled “Common mistakes”- Expecting
openapi.jsonto affect routing. On the default path it is documentation only. Runtime contract validation requires the opt-inopenapi-contractstrategy fromdev.vertique:vertique-rest-openapi-validation. - Omitting
RestValidationModuleand expecting validation. Without a module contributing a strategy, only the built-innonestrategy exists and no request gate is installed. - Assuming an application decoder or encoder is a fallback. Application implementations default to
priority
0, ahead of every framework default at 999–1100. Raise the priority above 1100 to sit behind them. - Using a temp upload file after the response completes. Cleanup is unconditional; move or copy the
file first. Holding the
FileUploadorEntityPartdoes not extend its lifetime. - Calling
EntityPart.getContent()twice. Both adapters are single-use. - Putting
@FilePartonEntityPartorList<EntityPart>. It is rejected at startup: anEntityPartmay be text-backed while the gate observes only file uploads, so accepting it would create a text-part bypass. - Mixing
@Contextwith a value-binding annotation.CONTEXT_PARAM_CONFLICTfails the build; the two are mutually exclusive by design. - Declaring
@Sanitize/@Canonicalizeon abyte[]orBufferbody. It fails startup. Both chain phases act on string values, and a raw binary body has none — useFileContentVerifieron a multipartFileUploadpart when the intent is to inspect uploaded content. - Expecting
@FilePart.maxSizeBytesto prevent a disk write. It is checked post-spool and returns 400. The ingress limits arehttp.maxBodySize(every request, 413),http.maxMultipartBodySizeBytes(multipart/form-data admission — effectivemin(maxBodySize, maxMultipartBodySizeBytes), 413, with Content-Length early reject before spool when present), andhttp.maxFormFields(part count). - Expecting
afterResponseto mean “the client has the bytes”. It fires at handoff; a streamed body may still be in flight. Observe the wire-completion channel for the delivery outcome. - Setting
@JsonProfileon a resource method and expecting the response to keep the class profile. Request and response profiles are symmetric — the method-level override applies to both. - Adding
name, a case variant such asOpenapiPath, or another setting inside ajaxrs.applicationsentry. The entry key is the application’s name, andopenapiPath, spelled exactly, is the only key an entry accepts; any other key fails startup instead of being ignored. - Spelling the section
jaxrs.Applicationsor another case variant. Only the exact spellingjaxrs.applicationsconfigures applications. A case variant fails startup withMiscased applications keys under 'jaxrs': …, even when no exactapplicationskey is present. A key that is not a case variant, such as the singularapplication, is still ignored like any unknownjaxrskey. - Hiding an input the method does not bind, once a documentation module is bound. A method-level
hiding entry —
@Parameter(name = ..., in = ..., hidden = true)on the method, in@Parameters, in@Operation(parameters = ...), or carried by a composed annotation — must name an input the method binds. Names match exactly, except header names, which match ignoring ASCII letter case; cookie names in hiding entries still match exactly. Only ASCII letters fold, so a non-ASCII look-alike such as U+212A KELVIN SIGN does not matchk. An unsetinmatches every location. A hidden entry that names no bound input fails startup with a configuration exception, and only when such a hook is bound.
Module Dagger Bindings
Section titled “Module Dagger Bindings”Beyond what RestCoreModule and JsonRuntimeModule contribute:
| Binding | Value |
|---|---|
RestExceptionMapper |
built by folding Set<RestExceptionMapperCustomizer> in OrderedExtension order |
DefaultExceptionMapper |
pre-configured with the mapping table above |
ExceptionMapperRegistry |
DefaultExceptionMapper + Set<ExceptionMapper<?>> |
List<RequestInterceptor> |
Set<RequestInterceptor> sorted once and shared by the serializer and the response pipeline |
List<ResponseBodyEncoder> / List<RequestBodyDecoder> |
sorted once in OrderedExtension order |
ResponseSerializer |
DefaultResponseSerializer, wired from the sorted interceptor and encoder lists |
SseChannelFactory |
@Singleton; the factory a resource method injects to create an SSE channel |
Set<ResponseBodyEncoder> |
the six framework encoders, SseBodyEncoder included |
Set<RequestBodyDecoder> |
the four framework decoders |
Set<RequestValidationStrategy> |
@Multibinds plus the built-in none strategy, so the set is never empty |
Set<FileContentVerifier> |
@Multibinds, empty by default |
Set<RestExceptionMapperCustomizer> |
@Multibinds, empty by default |
Set<GeneratedRestApplicationRegistration> |
@Multibinds; INTERNAL generated-code contract, populated by the annotation processor with one entry per declared @RestApplication; empty by default |
ApiDocsModuleInstalled |
@BindsOptionalOf; INTERNAL marker; the OpenAPI documentation module binds it when present, so Optional<ApiDocsModuleInstalled> is populated only when that module is in the component — see Contract location |
RestApplications |
@Provides @Singleton; INTERNAL; built once per component from Set<GeneratedRestApplicationRegistration>, the parsed jaxrs.applications, and Optional<ApiDocsModuleInstalled> — see The RestApplications view |
Set<GeneratedJaxRsResourceEntry> |
@Multibinds; INTERNAL generated-code contract, populated by the annotation processor with one entry per DI-eligible JAX-RS resource; empty by default |
Set<RouterMount> |
@ElementsIntoSet: with no @RestApplication declared, the default JaxRsRouterMount at jaxrs.basePath, empty when @JaxRsResources is empty; once one or more are declared, one mount per active application instead — see @RestApplication |
MountCompositionValidator (JaxRsApplicationMountValidator) |
@IntoSet; INTERNAL; takes the RestApplications view, JaxRsConfig, and Set<RequestValidationStrategy>; validates application mounts against hand-built JAX-RS mounts and against each other, the cross-mount operationId refusal, and, under a contract-driven strategy, the contract-location parse — see Mount conflicts |
ComposeValidator (JaxRsDefaultProfileValidator) |
@IntoSet; fails the VALIDATE phase on an unknown jaxrs.jsonProfile (json.systemProfile is validated earlier, by the CONFIGURE-phase install step) |
OperationSchemaSource, BeanValidator, InputObjectProcessor (dev.vertique.input.processing.InputObjectProcessor), ActionRegistry, Authorizer |
@BindsOptionalOf; satisfied by rest-validation, validation, sanitization, and rest-security respectively |
SyntheticOperationInstaller |
@Binds to a package-private implementation; INTERNAL; framework documentation module only; enforces a SyntheticOperation typed policy in the supported AuthModule and SecurityModule composition and refuses a restrictive one without it |
Set<MountPublicationHook> |
@Multibinds; INTERNAL; empty by default; sibling framework modules contribute: the documentation module through @ElementsIntoSet, the openapi-contract validation module’s contract-load check through @IntoSet |
dev.vertique.rest.jaxrs.runtime.MagicBytesVerifierModule is a separate opt-in @Module that
contributes the built-in magic-byte FileContentVerifier. It fails closed on a declared content
type that is present but unparsable, such as a wildcard type with a concrete subtype like */png
(rejection type fileContentTypeMalformed), reads a q or
other parameter on a mapped type without letting it skip the signature check, and looks the type up
lowercased with Locale.ROOT, so the check does not depend on the JVM’s default locale.
Three INTERNAL framework packages back these sibling-module seams. Each is outside the maturity promise and not a stable application API.
dev.vertique.rest.jaxrs.synthetic holds SyntheticOperationInstaller, which lets a sibling framework
module install a framework-owned route through the resource security chain, behind the completion
recorder, so its requests complete as RestRequestCompletedEvents; an installed route deliberately
bypasses two things a resource route would normally go through: the API-scoped middleware, request
interceptor, router-lifecycle-hook, and mount-customizer chains of a JAX-RS mount never run for it,
and its own failure handler ends every failure itself rather than handing it to the application’s
error pipeline.
A synthetic operation is built by one of three SyntheticOperation factories. authenticated and
withRoles keep their behavior and their two-annotation descriptor shape: the scheme’s
@SecurityRequirement plus @Authorized or @RolesAllowed, and no required action. withPolicy(origin, operationId, schemeName, applicationName, policy) carries a typed AccessPolicy (public, deny, roles,
scopes, a required action, or a combination) and reports it from accessPolicy(); the factory only
checks for null and does not judge the policy. The installer resolves the policy’s direct
requirements, refuses an invalid policy (not a valid policy interface, no requirement, an empty or
blank role, or an exclusive combination such as public with deny) with a RestConfigurationException
that starts with the operation’s origin and wraps the IllegalArgumentException, and does so before
the router gains any route.
The scheme follows the same rules a resource route has. An empty schemeName means the operation
names no scheme and is legal only for a policy that resolves to public access: it then gets no
@SecurityRequirement and no authentication handler, so it installs even where no scheme handler or
security module is composed. Any other value, a blank one included, is a supplied scheme. Public plus
a supplied scheme is rejected (a blank scheme name is refused earlier as a malformed security
requirement, a named scheme by the registered security policy validator); deny with an empty scheme
is rejected by that validator; deny with a registered scheme authenticates the caller and then denies; and
every other restrictive policy needs a registered scheme whose handler provides authentication. A
custom validator’s rejection stays authoritative, and no scheme is invented for a policy that has
none. With no policy validator bound at all, public plus a supplied scheme is not rejected.
A policy flows through the existing contributor chain: its requirements resolve to the effective
SecurityPolicy, and its action, once checked the way a resource route’s @RequiresAction is, is
passed to every operation handler contributor instead of “no action”. The action is refused, with
RouteRegistrationException violations raised before any route exists, when the authorization engine
(no ActionRegistry) is not installed, the REST authentication enforcement runtime is not installed,
no Authorizer is installed, or the action is not registered. Roles or scopes without the enforcement runtime are refused for the same reason a resource
route is. An action-only policy resolves to no SecurityPolicy restriction plus its action, as it does
on a resource route, so the action-gate contributor needs exactly one route authentication handler.
Enforcement of a typed policy is supported in a composition that includes AuthModule and
SecurityModule, directly or through a module that includes them. Where that composition is missing, a
restrictive policy is refused at installation rather than installed unenforced. Hand-binding only some
INTERNAL marker bindings of those modules is outside the supported composition: the installer does not
validate an arbitrary graph, and it does not repair how those markers pair.
dev.vertique.rest.jaxrs.publication holds MountPublicationHook, which is bound only through
the @Multibinds Set<MountPublicationHook> multibinding above — empty by default, never an
optional binding — to which sibling framework modules contribute (the documentation module through
@ElementsIntoSet, the openapi-contract validation module’s contract-load check through
@IntoSet); application developers must not implement it. Once one or more hooks are bound, every
JAX-RS mount hands each hook one MountPublication at the end of createRouter, naming an
application mount’s @RestApplication name and declaring interface (null for every other mount)
and carrying one OperationPublication per registered operation with the route value exactly as
registered and the operation’s effective security facts; a mount at least one hook wants detail for
also carries an OperationDetail: deep-copied CapturedSchemas, keyed by InputKey, taken before
the validation gate is built, plus the operation’s flattened input inventory and its response shape.
dev.vertique.rest.jaxrs.application holds RestApplications and ApiDocsModuleInstalled, the
composition view and the docs-module marker described under @RestApplication
above, public only so the JAX-RS application composer, the mount composition validator, the OpenAPI
documentation module, and the openapi-contract validation module’s contract-load check can read or
bind them.
Dependencies
Section titled “Dependencies”| Dependency | Why |
|---|---|
dev.vertique:vertique-rest-core |
every extension SPI this runtime consumes, the http/jaxrs config objects, ProblemDetail, BoundRequest’s RequestValue, the parameter-conversion stack, and RestCoreModule |
dev.vertique:vertique-input-processing |
the neutral InputObjectProcessor / EffectiveInputPolicies contracts the body pipeline and the optional sanitization binding are typed against |
dev.vertique:vertique-security-core |
SecurityContext and the authorization references the security policy resolves against |
dev.vertique:vertique-json |
JsonMapperProfileRegistry, JsonConfig (its effectiveProfile() is the vertique floor), and JsonRuntimeModule for per-method profile resolution; JacksonFieldNameResolver — the dev.vertique.core.sanitization.InputFieldNameResolver implementation supplying the body wire-name projection input processing keys its policies on |
io.swagger.core.v3:swagger-annotations-jakarta |
@Operation / @ApiResponse read at scan time for the operationId and, at build time, by the spec generator |
org.projectlombok:lombok |
provided scope — logging and accessors; not a runtime dependency |
Vert.x, Dagger, the JAX-RS API, and Jackson arrive transitively through vertique-rest-core.