REST core
View MarkdownStatus: Stable Package:
dev.vertique.rest.core(+ 18 sub-packages) Artifact:vertique-rest-coreDepends on: core, context, correlation, logging, security-core API reference: javadoc.io
vertique-rest-core is the extension contract for Vertique’s HTTP layer. It owns the HTTP server
verticle and its mount-composition algorithm, the per-request ordering spine, the configuration
objects for the server, and the transport-neutral interfaces that every other REST module and every
application extension registers against — middlewares, interceptors, body decoders and encoders,
context resolvers, parameter converters, security-scheme handlers, and request-completion listeners.
It is not a routing runtime. JAX-RS annotation scanning, request binding, dispatch, and response
serialization live in dev.vertique:vertique-rest-jaxrs. This module also carries no OpenAPI
dependency: route registration is expressed through neutral types (RouterSetup,
RouteRegistration, SecuritySchemeRegistry, RestOperationDescriptor) so an extension compiled
against it does not bind to a specific contract-validation implementation.
When To Use It
Section titled “When To Use It”Applications almost never depend on this artifact directly. Including
dev.vertique.starter.rest.RestApplicationModule (from dev.vertique:vertique-starter-rest) or
dev.vertique.rest.jaxrs.RestModule pulls it in transitively, because RestModule includes
RestCoreModule.
Declare an explicit dependency when you:
- implement a framework extension point — a
Middleware, interceptor,RequestBodyDecoder,ResponseBodyEncoder,ParamConverter,RestContextResolver,OperationHandlerContributor,RestRequestCompletedListener, orHttpRequestCompletedListener; - mount a non-JAX-RS sub-router (static assets, a health tree, a hand-written Vert.x router) beside
the JAX-RS mount via
RouterMount; - return
ProblemDetailbodies, pagination envelopes, or SSE streams from resource methods; or - build a library that must compile against the REST contract without depending on the JAX-RS runtime.
Pairs with dev.vertique:vertique-rest-jaxrs (routing and dispatch),
dev.vertique:vertique-rest-security (authentication, authorization, identity),
dev.vertique:vertique-rest-validation (the default request-validation gate), and
dev.vertique:vertique-rest-websocket.
Core Concepts
Section titled “Core Concepts”Mount composition
Section titled “Mount composition”One HttpVerticle owns the main Vert.x Router. Everything reachable over HTTP arrives as a
RouterMount — a sub-router attached at a path prefix. The JAX-RS mount is one such mount; static
handlers, health endpoints, or a hand-built router are peers of it, not special cases.
HttpVerticle starts in this order, and each step is observable:
- Sort mounts —
phase→priority()→mountPath()→orderKey(). ThemountPathtie-break sits beforeorderKeyso that, at equal phase and priority,/api/*mounts ahead of a broader/*catch-all. - Validate mount paths — every violation is collected and the start promise fails with one aggregated message. Because validation runs after the sort, violations are reported in mounted order.
- Run composition validators — every framework composition validator (see Framework seams below) runs on the valid, sorted mounts; any violation it returns, or any exception it throws, fails the start promise before any mount router is created.
- Detect overlaps — duplicate paths and prefix containment log a warning; startup continues.
- Sort
MountCustomizers by the plainOrderedExtensioncomparator. - Create the main router and attach every
ROOT-scopedMiddlewareat its ownpath(). - Run
BEFORE_MOUNTSRouterCustomizers. - Create each mount’s router sequentially, apply every matching
MountCustomizer, attach as a sub-router. Creation is sequential precisely so mount order equals the sorted order. - Run
AFTER_MOUNTSRouterCustomizers. - Bind the server, then publish the bound port into
vertx.sharedData().getLocalMap("vertique")under the keyhttp.port.
A failed createRouter(...) future fails startup.
Extension ordering
Section titled “Extension ordering”Every ordered extension in this module implements dev.vertique.core.extension.OrderedExtension and
sorts by phase → priority → orderKey. Phase dominates priority: a SYSTEM_FIRST extension always
precedes an APPLICATION one regardless of numeric priority. RouterMount adds the mountPath
tie-break described above; nothing else does.
Two interfaces deliberately re-declare priority() as abstract, suppressing the
OrderedExtension default so no implementation lands in a band by accident:
MiddlewareOperationHandlerContributor
Middleware scope
Section titled “Middleware scope”Middleware.scope() selects where a handler is attached:
| Scope | Attached by | Applies to |
|---|---|---|
ROOT (default) |
HttpVerticle, on the main router at path() |
every request |
API |
the JAX-RS mount in vertique-rest-jaxrs |
validated JAX-RS routes only |
HttpVerticle mounts only ROOT. A custom non-JAX-RS RouterMount silently drops API-scoped
middlewares — if a handler must run for such a mount, give it ROOT scope and a narrower path().
The per-request spine
Section titled “The per-request spine”RequestContextLifecycle is a ROOT middleware at phase SYSTEM_FIRST, priority
Integer.MIN_VALUE. It is the single owner of per-request cleanup. It registers exactly one Vert.x
end handler, first — and because Vert.x Web fires addEndHandler callbacks in reverse
registration order, its cleanup runs last. Every value bound during the request (security
context, MDC keys, correlation) therefore stays readable by every other end handler.
A reroute re-runs every ROOT middleware on the same request, this one included. The lifecycle
reuses the request’s own Handle and registers no second cleanup, so “exactly one end handler per
request” holds across reroutes. Every value bound on any pass, including an identity authenticated
only after a reroute, stays readable by every other end handler until the request ends. A handle
from another request is never reused. A handle closed by completeNow() is never reopened: a
reroute after it chains a successor handle, which the same single cleanup closes. A rerouted request
without an inbound X-Request-Id gets a generated request id on each pass; its completion event
carries the last pass’s id, the same one the echoed response header carries.
The contract that follows from this: no other component calls ctx.addEndHandler(...) for
cleanup. Register on the Handle instead.
RequestContextLifecycle.Handle lifecycle = RequestContextLifecycle.fromRoutingContext(ctx);lifecycle.onClose(securityRuntime.bindCurrent(securityContext)); // closed LIFOlifecycle.bindMdc(Map.of(MdcKeys.USER_ID, userId)); // MDC scope, closed with the restlifecycle.afterClose(() -> metrics.recordRequest()); // runs FIFO, after every onClosectx.next();Framework middlewares occupy these positions:
| Middleware | Phase | Priority | Scope |
|---|---|---|---|
RequestContextLifecycle |
SYSTEM_FIRST |
Integer.MIN_VALUE |
ROOT |
RestRequestCompletionEmitter |
SYSTEM_FIRST |
RequestContextLifecycle.ORDER + 5 |
ROOT |
CorrelationIngressMiddleware |
default | RequestContextLifecycle.ORDER + 10 |
ROOT |
ContextualLoggingMiddleware |
default | 0 |
ROOT |
DefaultHeadersMiddleware |
default | 10 |
ROOT |
ContentTypeValidationMiddleware |
default | 20 |
API |
ContentTypeValidationMiddleware decides whether a POST, PUT or PATCH has a body the same way as the
per-route @Consumes 415 gate: a non-empty body the body handler read, Transfer-Encoding, a
non-zero Content-Length, or a request the body handler treats as multipart or form (by its
Content-Type). An HTTP/2 body sent in DATA frames without a length therefore counts, and a declared
Content-Length: 0 without Transfer-Encoding is empty.
RequestContextLifecycle.ORDER and CorrelationIngressMiddleware.ORDER are public and may be used
as anchors. An application middleware that must observe an authenticated identity belongs at a
positive priority. RestRequestCompletionEmitter runs in SYSTEM_FIRST right after
RequestContextLifecycle, so an application ROOT middleware that ends the response without
calling next(), at any priority, still yields exactly one completion event; the phase is a trusted
ordering hint, not a security boundary.
Request completion
Section titled “Request completion”Exactly one completion event is published for each HTTP request that completes through the response lifecycle, owned by the transport that claimed it:
- A
RestRequestCompletedEventwhen a JAX-RS operation route matched. The claim is recorded by a platform handler (a Vert.x WebPlatformHandler) that the JAX-RS route registrar installs first on every operation route, before authentication. So a request denied with 401, 403, 415, or a validation 400 on a matched route still carries its non-nulloperation(). - Nothing when another transport claimed the request to emit its own lifecycle events.
- An
HttpRequestCompletedEventotherwise, carrying the transport facts: method, path, status, timing, failure classification, an optional security snapshot, an optional correlation snapshot, and the request origin. The REST event carries the same facts plus itsoperation().
Unclaimed requests are:
ROOTrejections, for example a correlationREJECT400 or a rate-limit 429;- requests that match no route (404 or 405);
- JAX-RS mount-level rejections before any operation route matched: body 413 or 400, request-interceptor rejections, and the API-scope 415;
- MCP admission rejections on a request’s first entry into the MCP mount;
- failed WebSocket upgrades.
Entering a JAX-RS mount, or reaching its failure handler, does not claim a request. The last
operation route matched in the current routing pass decides the operation. A reroute clears the
claim, so the rerouted target decides it, and the event keeps the first pass’s start time. At
DEBUG, RestRequestCompletionEmitter logs one line for each request it skips because another
transport claimed it.
The completion state is framework-owned. No RoutingContext.data() key exposes the start time,
emitted flag, claim, operation identity, or post-handoff wire-failure marker, and writing the
retired rest.events.* keys or the retired vertique.rest.core.events.wireFailure key has no
effect on the event. RestRequestCompletionEmitter holds the state in its own end handler and
emits exactly once whether or not RequestContextLifecycle is mounted. A sibling framework module
records a post-handoff wire failure through RequestCompletionRecorder.recordWireFailure (first
writer wins); the emitter still consults a failed response end-handler result when no marker was
recorded, including the documented late-end() carve-out. The JAX-RS route registrar reads the
recorded operation back through RequestCompletionRecorder.recordedOperation, so a route’s
error-body decision follows the route that matched, not the order in which overlapping routes were
registered.
Two deliberate properties:
- A successful protocol upgrade emits no event — the 101 is written without firing the response
end handler. A failed upgrade takes the normal error path and emits an
HttpRequestCompletedEvent. safeFailureMessageis alwaysnull, andfailureCodeis a class simple name only, on both event types. Raw exception messages can carry SQL text, upstream detail, or PII, so they never reach either event.
Which completion source do I use?
Section titled “Which completion source do I use?”| To observe | Use | Module |
|---|---|---|
| JAX-RS operations, including requests denied on a matched operation route | RestRequestCompletedListener |
vertique-rest-core |
| HTTP requests no transport claimed (the list above) | HttpRequestCompletedListener |
vertique-rest-core |
| MCP requests | MCP completion observers: McpRequestLifecycleObserver, or McpRequestCompletedListener for a post-transport callback |
vertique-mcp-core |
| WebSocket connections after a successful upgrade | WebSocket channel events: ChannelOpenedEvent and ChannelClosedEvent through SecurityEventObserver, for connections registered as channels |
vertique-security-core; vertique-rest-websocket says when a connection is registered |
| Every HTTP request at the transport level, whatever claimed it, as counts and latencies | Vert.x native HTTP server metrics (metrics.vertx.httpServer) |
vertique-micrometer-core |
Neutral route registration
Section titled “Neutral route registration”OperationHandlerContributor is how a module injects a handler into a single operation’s chain.
Contributors receive an OperationRegistrationContext exposing the operation id, the resolved
SecurityPolicy, an optional ActionRef, the RestOperationDescriptor, and a RouteRegistration
to append handlers to. The terminal operation invoker is always appended after every
contributor.
RestOperationDescriptor.effectiveSecurityPolicy() is the always-on fail-closed security gate: it
calls EffectiveSecurityPolicy.enforceSupportedShape(...) before folding, so simply reading the
effective policy throws RestConfigurationException for an unsupported declaration shape whether or
not the optional validator from vertique-rest-security is wired.
RestOperationDescriptor is Stable and grows only through default methods, so an implementation
written against an earlier version (such as a test descriptor) keeps compiling and linking.
Key Classes
Section titled “Key Classes”ProblemDetail
Section titled “ProblemDetail”RFC 9457 problem-details response body — the default error shape for every built-in exception mapper.
All fields are optional and omitted from JSON when null. Extension members declared through
extension(...) are serialized as sibling JSON fields. The REST error pipeline gives a problem
without an instance an opaque urn:uuid:<random UUID> occurrence id, which is also on the failure’s
log line, and never a request-derived value (the request path carries the caller’s bytes and a route
template is not a URI reference). An instance set in your own mapper is kept as written; it then
identifies the occurrence, so make it unique per occurrence and log it yourself.
ProblemDetail simple = ProblemDetail.of(404, "Item 123 not found");ProblemDetail withInstance = ProblemDetail.of(404, "Item 123 not found", "/items/123");
ProblemDetail detailed = ProblemDetail.builder() .type("https://api.example.com/problems/quota-exceeded") .title("Quota Exceeded") .status(429) .detail("Daily quota of 100 requests exhausted") .extension("limit", 100) .extension("resetsAt", "2026-01-01T00:00:00Z") .build();ProblemDetail.of(int, String) derives type "about:blank" and a title from the status code
(ProblemDetail.titleForStatus(int) exposes the same mapping).
A typed subclass uses @SuperBuilder and must repeat the Jackson annotations, because
ProblemDetail is @Accessors(fluent = true) and getter-based serialization must stay suppressed:
package com.example.api;
import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY;import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE;
import com.fasterxml.jackson.annotation.JsonAutoDetect;import com.fasterxml.jackson.annotation.JsonInclude;import dev.vertique.rest.core.ProblemDetail;import lombok.AccessLevel;import lombok.Getter;import lombok.experimental.Accessors;import lombok.experimental.FieldDefaults;import lombok.experimental.SuperBuilder;
@Getter@SuperBuilder@Accessors(fluent = true)@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)@JsonInclude(JsonInclude.Include.NON_NULL)@JsonAutoDetect(fieldVisibility = ANY, getterVisibility = NONE)public class QuotaProblemDetail extends ProblemDetail {
int limit;
public static QuotaProblemDetail of(int limit) { return QuotaProblemDetail.builder() .type("https://api.example.com/problems/quota-exceeded") .title("Quota Exceeded") .status(429) .detail("Daily quota of " + limit + " requests exhausted") .limit(limit) .build(); }}ValidationProblemDetail and ValidationErrorDetail
Section titled “ValidationProblemDetail and ValidationErrorDetail”ValidationProblemDetail extends ProblemDetail with an errors list.
ValidationProblemDetail.of(String detail, List<ValidationErrorDetail> errors) produces a 400
about:blank / "Bad Request" body.
public record ValidationErrorDetail( @Nullable String pointer, // RFC 6901 JSON Pointer into the request document, e.g. "/items/0/name" @Nullable String parameter, // non-body request location, e.g. "page", "X-Token" String detail, // human-readable failure description @Nullable String location, // "body", "query", "header", "path", "cookie", "form", "file" @Nullable String type, // classification: "required", "size", "min", "pattern", … @Nullable Map<String, Object> args // constraint arguments, e.g. {"min":1,"max":100}) { public static ValidationErrorDetail ofPointer(String pointer, String detail) { … } public static ValidationErrorDetail ofParameter(String parameter, String detail) { … } public static List<ValidationErrorDetail> ofViolations(ViolationCarrier carrier) { … } public ValidationErrorDetail withLocation(@Nullable String location) { … } public ValidationErrorDetail withType(@Nullable String type) { … } public ValidationErrorDetail withArgs(@Nullable Map<String, Object> args) { … }}pointeris an RFC 6901 JSON Pointer into the request document over the wire names the request was bound with.""is a valid pointer (the whole document) and is serialized, so test for presence, not truthiness.parameternames a non-body request location (query parameter, header, path variable, cookie, form field).- At most one of
pointerandparameteris set; the canonical constructor throwsIllegalArgumentExceptionwhen both are present, which also rejects a malformed response body read on the client side. An entry carries neither when the location cannot be stated. - The
with…methods return a copy that carries every other member over. ofViolations(ViolationCarrier)renders a carrier’s violations one entry each in order and without a cap, withdetail,typeandargsand no location: a detail’s path is a location in the value its producer validated, not in the request.ofViolations(ViolationCarrier, String basePointer)is for code that knows the validated value sits atbasePointerin the request body (""when it is the body): aJSON_POINTERdetail whose joined pointerViolationPathForm.acceptsadmits getspointerandlocation"body", every other detail stays without a location. Neither logs. Use them from an application’s own exception mapper to reuse the framework’s rules.
null components are omitted from JSON and unknown members are ignored when reading. args is
populated for standard Jakarta constraints by the ViolationArgsInspector SPI in
dev.vertique:vertique-validation.
RestValidationException
Section titled “RestValidationException”Extends dev.vertique.core.exception.ValidationException with a structured, unmodifiable
List<ValidationErrorDetail> (errors()). Throw it to produce a 400 with a
ValidationProblemDetail body.
RequestPreconditions
Section titled “RequestPreconditions”Conditional-request evaluation (If-Match, If-None-Match, If-Modified-Since,
If-Unmodified-Since) per RFC 9110. Inject it as a resource-method parameter, or build it from a
routing context with RequestPreconditions.from(ctx) — the instance is cached per request.
@GET@Path("/items/{id}")public Response getItem(@PathParam("id") String id, RequestPreconditions preconditions) { Item item = repository.find(id); Response notModified = preconditions.evaluate(new EntityTag(item.version()), item.updatedAt()); if (notModified != null) { return notModified; // 304 or 412, already built } return Response.ok(item).tag(new EntityTag(item.version())).build();}evaluate(...) returns null when the request should proceed, a 304 for a satisfied
If-None-Match/If-Modified-Since on GET or HEAD, and a 412 otherwise. evaluate(Response) reads
the ETag and Last-Modified off an already-built response.
@RequestParams
Section titled “@RequestParams”Class-level equivalent of JAX-RS @BeanParam. A method parameter whose type carries
@RequestParams is populated from the request with no annotation on the parameter itself. Fields may
carry @QueryParam, @PathParam, @HeaderParam, @CookieParam, @FormParam, and @DefaultValue.
@RequestParams@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)@JsonIgnoreProperties(ignoreUnknown = true)public record ItemFilter( @QueryParam("status") @Nullable String status, @QueryParam("tenantId") @Nullable String tenantId, @HeaderParam("X-Request-Source") @Nullable String requestSource) {}
@GET@Path("/items")public Future<List<Item>> listItems(ItemFilter filter) { … }@FilePart
Section titled “@FilePart”Declares multipart upload constraints on a resource parameter.
public @interface FilePart { String[] allowedTypes() default {}; // exact "type/subtype" or whole-subtype wildcard "image/*" long maxSizeBytes() default -1; // -1 = unconstrained; otherwise must be positive}Supported shapes are a named @FormParam FileUpload, a named @FormParam List<FileUpload>, and an
unannotated aggregate List<FileUpload>. EntityPart is excluded because a part may be a text field
the upload gate cannot observe. Invalid placement, invalid allowed-type grammar, a size other than
-1 or positive, and overlapping constrained declarations all fail route startup.
Size enforcement is post-spool: ingress body size is bounded by http.maxBodySize for every
request and by http.maxMultipartBodySizeBytes for multipart/form-data (effective limit is the
tighter of the two; exceeding it returns 413, and when Content-Length is present Vert.x rejects
before creating upload files). maxSizeBytes is checked after Vert.x has written the part under
http.uploadsDirectory. Part count is bounded separately at ingress by http.maxFormFields, so a
request with more parts than that is rejected during decoding whatever their individual sizes.
Pagination
Section titled “Pagination”Two response envelopes and their matching @RequestParams records.
Offset (OffsetPage) |
Cursor (CursorPage) |
|
|---|---|---|
| Navigation | random access by page number | sequential (next/previous) |
| Total count | exposed (totalItems, totalPages) |
not exposed |
| Ordering stability | drifts when rows are inserted or deleted | stable |
| Cost at depth | OFFSET N degrades |
constant, via keyset pagination |
| Best for | admin UIs, page-numbered lists | feeds, timelines, high-volume APIs |
public record OffsetPage<T>( List<T> items, long totalItems, int totalPages, int page, int pageSize, boolean first, boolean last) {
public static <T> OffsetPage<T> of(List<T> items, long totalItems, int page, int pageSize) { … }}
public record CursorPage<T>(List<T> items, @Nullable String nextCursor, @Nullable String previousCursor) {
public boolean hasMore(); // @JsonIgnore public boolean hasPrevious(); // @JsonIgnore
public static <T> CursorPage<T> of( List<T> items, @Nullable String nextRawCursor, @Nullable String prevRawCursor, CursorCodec codec) { … }}OffsetPageRequest reads page, size, and sort; CursorPageRequest reads cursor and
pageSize. Both clamp defensively — page(int) floors at 0, pageSize(int) floors at 1, and
pageSize(int, int) additionally caps at the supplied maximum. OffsetPageRequest.sortOrders()
parses sort=name,desc,createdAt into SortOrder(field, ASC|DESC) entries, where a bare asc/desc
token applies to the preceding field.
@GET@Path("/items")public Future<CursorPage<Item>> listItems(CursorPageRequest request) { int size = request.pageSize(20, 100); String after = request.decodeCursor(cursorCodec).orElse(null); return repository.findAfter(after, size) .map(rows -> CursorPage.of(rows.items(), rows.nextKey(), rows.previousKey(), cursorCodec));}Encode cursors with HmacCursorCodec on any endpoint whose cursor encodes internal keys — it signs
the token, supports key rotation by id, and enforces an optional TTL. PlainCursorCodec.INSTANCE is
the pass-through default used by the codec-less CursorPage.of/decodeCursor overloads.
CursorCodec codec = new HmacCursorCodec("2026-01", secret, Duration.ofHours(1));Each HmacCursorCodec.Key id must match [A-Za-z0-9_-]+, and each secret must be at least 32 bytes
UTF-8 encoded. The first key in the list signs; every key can verify. A tampered, unknown-key, or
expired token raises InvalidCursorException.
Server-Sent Events
Section titled “Server-Sent Events”SseChannelFactory is injectable; the resource method returns channel.stream(). The framework
detects a ReadStream<SseEvent> return type at startup and installs the SSE encoder — no extra
configuration. Client disconnect mid-stream is observed through RoutingContext.addEndHandler
(multicast), so completion emission and RequestContextLifecycle cleanup still run; do not register
on the response’s single-slot closeHandler / endHandler / exceptionHandler for the same purpose
(see Common mistakes).
@Path("/jobs")public class JobResource {
private final JobService jobs; private final SseChannelFactory channels;
@Inject public JobResource(JobService jobs, SseChannelFactory channels) { this.jobs = jobs; this.channels = channels; }
@GET @Path("/{jobId}/events") @Produces("text/event-stream") public ReadStream<SseEvent> streamJobEvents(@PathParam("jobId") String jobId) { SseChannel channel = channels.create(); channel.onClose(() -> jobs.unsubscribe(jobId)); jobs.subscribe(jobId, update -> { channel.send(SseEvent.builder().id(update.sequence()).event(update.type()).data(update).build()); if (update.isFinal()) { channel.complete(); } }); return channel.stream(); }}public interface SseChannel { ReadStream<SseEvent> stream(); Future<Void> send(SseEvent event); Future<Void> send(Object data); Future<Void> send(String event, Object data); void complete(); void fail(Throwable cause); boolean isClosed(); SseChannel onClose(Runnable handler);}SseEvent has five optional properties — id, event, data, comment, retryMs — built with
SseEvent.builder(), or SseEvent.of(Object data) / SseEvent.comment(String text). data is an
arbitrary object serialized by the SSE encoder, not a pre-rendered string.
create(SseChannelOptions) overrides the buffer for one channel;
new SseChannelOptions(int bufferSize, BufferOverflowPolicy overflowPolicy) requires
bufferSize >= 1 and a non-null policy, and SseChannelOptions.defaults() returns (256, FAIL).
BufferOverflowPolicy.FAIL fails the send(...) future when the buffer is full;
DROP_OLDEST evicts the oldest buffered event instead.
HeaderElement, MediaType and AcceptNegotiator
Section titled “HeaderElement, MediaType and AcceptNegotiator”HeaderElement is the shared, typed parser for comma-separated header values of the shape
value *( ; parameter ) with an optional q weight — Accept, Accept-Language and
Accept-Encoding all share it. HeaderElement.parseList(String) returns an immutable
List<HeaderElement> in header order; HeaderElement.parse(String) reads one element, treating a
comma as an ordinary character, and returns null for blank or malformed input. Each element
carries value (the token before the first ;, trimmed), parameters (lowercase names, header
order, immutable, q excluded, last duplicate wins) and quality (double in [0, 1], default
1.0). Neither method throws.
Parsing follows RFC 9110 and applies the Accept-style rules below. Content-Type is read by
MediaType.parse, which is more lenient about q only — see the MediaType paragraph below.
- Elements split on
,and parameters on;only outside double-quoted strings. Inside a quoted string a backslash escapes the next character, and a quoted parameter value is returned unquoted and unescaped (profile="a,b"yieldsa,b). Outside quotes a backslash is literal. - A malformed element is skipped, never read leniently, so it cannot make anything
acceptable: an unterminated quoted string (which also swallows the rest of the header), characters
after a closing quote, a quote inside a parameter name, an empty value, or an invalid
q. qmust be a valid qvalue:0,1,0.xxxor1.000(at most three decimals). A quotedq, an unparsableq, a negative value, a value above1and a repeatedqare malformed; none is clamped or defaulted to1.0.- A parameter without
=or with an empty name is ignored. Empty elements (a,,b, a trailing comma) and empty parameters are skipped. - At most the first
HeaderElement.MAX_ELEMENTS(50) non-empty elements are considered, counting malformed ones; later elements are not parsed and parsing stops once they have been found, so a megabyte of commas costs one pass and no per-comma allocation.HeaderElement.parseListdoes not report that more elements followed;AcceptNegotiator.negotiatecounts a non-empty element past the cap as a malformed entry (see below), so a long run of ignorable entries cannot push a malformed one out of sight. - An element with more than
HeaderElement.MAX_PARAMETERS(32) non-empty parameters is malformed, not truncated: extra parameters are never silently ignored. Scanning stops as soon as the cap is exceeded.
Two static helpers expose the same tokenizer for header grammars that are not
value *( ; parameter ) — Cache-Control directives, or the parameters after the <uri> of a
Link value — so every parser here splits and unquotes identically:
HeaderElement.splitOutsideQuotes(String text, char delimiter)splits ondelimiteronly outside double-quoted strings, honoring backslash escapes inside them. Segments are untrimmed and empty segments are kept. It returnsnullwhen a quoted string is unterminated — callers must check fornulland treat it as malformed, never as “no segments”;delimitermust not be"or\.HeaderElement.unquote(String value)returns a value that does not start with"unchanged, and otherwise the content of one complete quoted string with its escapes resolved. It returnsnullwhen the closing quote is missing (including a trailing backslash or a final escaped quote) or characters follow it; callers must check fornull.
MediaType is an immutable RFC 9110 media type — type, subtype, parameters (excluding q), and
quality factor, with type and subtype lowercased using Locale.ROOT, so the result does not depend on
the JVM’s default locale (IMAGE/PNG is image/png under a Turkish default locale too).
MediaType.parse(String) (aliased as valueOf) reads one
element the way a Content-Type is read, and returns null for null, blank, or structurally
malformed input (an unterminated quote, characters after a closing quote, an empty value, an empty
type or subtype, a control character in the type, the subtype or a parameter name, more than 32
parameters) rather than throwing. The public constructor throws IllegalArgumentException for a
control character (below U+0020, or U+007F) in the type, the subtype or a parameter name. Unlike the Accept path, q is an ordinary parameter here: a
numeric q is clamped to [0, 1], and an unparsable, quoted or NaN q is ignored (the factor
stays 1.0); an unusable q never makes the media type null. A caller that guards a trust
boundary with MediaType.parse must treat null for a non-blank input as a rejection, not as
“nothing declared”. Quoted-string parameter values appear unquoted in parameters(), and
toString() re-quotes values that are not tokens and writes each control character other than a
horizontal tab in them as _, so a value never carries a line break into a header and two values
that differ by a control character stay different (evil.php with U+0001 before the final p is
written evil.ph_p, never evil.php); a value that held a control character therefore parses back with _ in its place. isCompatible(MediaType) is wildcard-aware
and ignores parameters. A wildcard type with a concrete subtype such as */xml is not a media range:
MediaType.parse returns null for it, an Accept entry of that shape is dropped as malformed, and
isCompatible treats only */* as matching every type. specificity() returns 0 for */*, 1 for type/*, 2 for type/subtype, and
3 when parameters are present. Equality ignores the quality factor.
AcceptNegotiator.negotiate(String acceptHeader, List<String> serverTypes) returns the best matching
server type as "type/subtype", or null when nothing matches — the signal a caller turns into a
406. A null/blank Accept header, or one whose entries merely lack a / (garbage), yields the first
server type; an empty serverTypes yields null. An Accept header with an entry dropped as
malformed (an unterminated quote, an invalid, quoted, out-of-range or repeated q, too many
parameters, a / with an empty type or subtype such as text/ or /json, or a wildcard type with a
concrete subtype such as */xml) and no usable entry is
not “anything goes”: the result is null. So is a header with a non-empty entry past the 50-element
cap and no usable entry among the first 50: the entries beyond the cap are never read, and their
existence counts as malformed, so 50 slashless tokens followed by text/html;q=abc is null, not the
first server type. Tokens that merely lack a / are still skipped without counting as malformed.
parseAcceptHeader(String) returns the well-formed entries sorted by q-value then specificity, both
descending, under the 50-element cap, and does not report what lay past it. A server type passed to negotiate is read with MediaType.parse, so its q
handling is the lenient Content-Type one.
AcceptNegotiator.effectiveQuality(String acceptHeader, String mediaType) (and the overload over
parsed List<MediaType> entries and a MediaType) answers whether one exact type is acceptable:
it returns the quality of the most specific compatible entry, and 0.0 — not acceptable — when no
entry is compatible. Unlike negotiate, it does not fail open: a null, blank or entirely
malformed Accept header and an unparsable mediaType all yield 0.0. Use it to gate a single
representation, and negotiate to choose among several.
MdcKeys
Section titled “MdcKeys”Constants for the MDC keys framework middlewares emit. Use them instead of literals so log pipelines do not drift.
| Constant | Key | Emitted by |
|---|---|---|
REQUEST_ID |
requestId |
CorrelationIngressMiddleware |
METHOD |
method |
ContextualLoggingMiddleware |
PATH |
path |
ContextualLoggingMiddleware |
USER_ID |
userId |
identity resolution in vertique-rest-security |
CLIENT_ID |
clientId |
identity resolution in vertique-rest-security |
AUTH_METHOD |
authMethod |
identity resolution in vertique-rest-security |
@Authorized ownership
Section titled “@Authorized ownership”The @Authorized annotation is now owned by vertique-security-core as
dev.vertique.security.authz.Authorized; use that import for supported REST and WebSocket
declarations. Its existing scope matching and role-composition behavior is unchanged. This
owner-authorized 0.x move is a source and binary break for the former
dev.vertique.rest.core.security.Authorized import. No deprecated REST alias is provided; update
imports and clean-rebuild with aligned framework and processor versions.
The resolved shape is a SecurityPolicy — a sealed interface with None, PermitAll, DenyAll,
AuthenticatedOnly, and Constrained(requiredRoles, requiredScopes, requireAllScopes) — reachable
from OperationRegistrationContext.securityPolicy() and
RestOperationDescriptor.effectiveSecurityPolicy().
Security annotations are collected from every declaration a method overrides, under the Java override rules (bound type variables of generic declarations, protected and same-package declarations; see the vertique-rest-jaxrs module for precedence). @RequiresPolicy names an AccessPolicy interface. AnnotationSecurityPolicyResolver expands the
selected policy’s direct requirements into that same SecurityPolicy. RequiresActionResolver
reads @RequiresAction from the same selection. A method policy replaces a type policy. Inline
annotations with no policy keep their existing precedence.
JaxRsResources
Section titled “JaxRsResources”Dagger qualifier for the Set<Object> multibinding of JAX-RS resource instances. Generated resource
registration contributes into it; a hand-wired resource uses it directly.
@Provides@IntoSet@JaxRsResourcesstatic Object itemResource(ItemResource resource) { return resource;}In vertique-rest-jaxrs, once any Jakarta REST Application is declared, this set holds every manual
contribution — selected by an application or not — plus any resource contributed by a module built with
an older processor, which still contributes unconditionally. A resource generated by the current
processor reaches an application’s mount only through the declared applications that select it, not
through this set. With no Application declared, this set’s content is unchanged: generated and
hand-wired resources alike contribute into it exactly as before.
@RestApplication
Section titled “@RestApplication”Declares a named REST application on an interface. It is Beta and outside this module’s Stable
promise: it may change in a later release, and only with a migration note. Package
dev.vertique.rest.core.application.
public @interface RestApplication { String name(); // required String path(); // required; the application's mount path Class<?>[] resources() default {}; // listed resources, in the order written boolean discover() default false; // discover resources at startup instead String openapiPath() default ""; // "" = the global jaxrs.openapiPath}It annotates an interface only; the interface is never implemented or instantiated. Exactly one of a
non-empty resources and discover = true is set, and discover = true is permitted only when it
is the compilation unit’s sole declaration. name must match [a-z0-9][a-z0-9_-]{0,63} and must
not be none or null. vertique-codegen-jaxrs validates and registers each declaration at
compile time, and vertique-rest-jaxrs composes and mounts the declared applications at runtime.
Composition, membership, the jaxrs.applications.<name> configuration, and mount conflicts are
described in the “@RestApplication” section of dev.vertique:vertique-rest-jaxrs.
Extension Points
Section titled “Extension Points”All sets below are declared as @Multibinds in RestCoreModule, so contributing is always
@Provides @IntoSet — no set needs to be created first, and an application that contributes nothing
still builds.
RouterMount
Section titled “RouterMount”Provides a sub-router mounted at a path prefix.
public interface RouterMount extends OrderedExtension { default String mountPath() { return "/*"; } Future<Router> createRouter(Vertx vertx); default MountMeta meta() { return new MountMeta(getClass().getName(), mountPath(), null, Set.of()); }}mountPath() must start with / and end with /*. meta() supplies the identity
MountCustomizers match on: MountMeta(String mountId, String mountPath, @Nullable String openapiPath, Set<Class<?>> resourceTypes, @Nullable String applicationName). Override it to publish a stable
mountId. applicationName is the name of the application the mount serves and is null for a mount
that belongs to no named application, including the default meta() above.
applicationName is a nullable component appended after resourceTypes. The four-argument
constructor MountMeta(mountId, mountPath, openapiPath, resourceTypes) remains and supplies null, so
code that constructs a MountMeta with four arguments keeps compiling. A record pattern that
deconstructs MountMeta into four components no longer compiles against the five-component record;
read the components through the accessors instead.
@Provides@IntoSetstatic RouterMount staticAssets() { return new RouterMount() { @Override public String mountPath() { return "/assets/*"; } @Override public int priority() { return 100; } @Override public Future<Router> createRouter(Vertx vertx) { Router router = Router.router(vertx); router.route().handler(StaticHandler.create("webroot")); return Future.succeededFuture(router); } };}MountCustomizer
Section titled “MountCustomizer”Runs after a mount’s router is created and before it is attached.
public interface MountCustomizer extends OrderedExtension { default boolean matches(MountMeta meta) { return true; } void customize(Router mountRouter, MountMeta meta);}@Provides@IntoSetstatic MountCustomizer apiRateLimit(RateLimitHandler handler) { return new MountCustomizer() { @Override public boolean matches(MountMeta meta) { return meta.mountId().startsWith("jaxrs:"); } @Override public void customize(Router router, MountMeta meta) { router.route().handler(handler); } };}RouterCustomizer
Section titled “RouterCustomizer”Customizes the main router. mountPhase() partitions customizers into two groups run before and
after all sub-routers are attached; within a group, ordering is the standard comparator.
public interface RouterCustomizer extends OrderedExtension { void customize(Router router); default MountPhase mountPhase() { return MountPhase.BEFORE_MOUNTS; }
enum MountPhase { BEFORE_MOUNTS, AFTER_MOUNTS }}@Provides@IntoSetstatic RouterCustomizer spaFallback() { return new RouterCustomizer() { @Override public MountPhase mountPhase() { return MountPhase.AFTER_MOUNTS; } @Override public void customize(Router router) { router.get("/*").handler(StaticHandler.create("webroot").setIndexPage("index.html")); } };}Framework CORS is already contributed as a BEFORE_MOUNTS customizer driven by the cors config
section — configure it rather than adding a second CORS handler.
Middleware
Section titled “Middleware”public interface Middleware extends Handler<RoutingContext>, OrderedExtension { @Override int priority(); // abstract: must be declared default MiddlewareScope scope() { return MiddlewareScope.ROOT; } default String path() { return "/*"; }}@Singletonpublic final class TenantMiddleware implements Middleware {
@Inject public TenantMiddleware() {}
@Override public int priority() { return 50; // after identity resolution }
@Override public void handle(RoutingContext ctx) { RequestContextLifecycle.Handle lifecycle = RequestContextLifecycle.fromRoutingContext(ctx); lifecycle.bindMdc(Map.of("tenantId", ctx.request().getHeader("X-Tenant-Id"))); ctx.next(); }}@Provides@IntoSet@Singletonstatic Middleware tenantMiddleware(TenantMiddleware middleware) { return middleware;}RouterLifecycleHook
Section titled “RouterLifecycleHook”Phase hooks around router construction. Every method has a no-op default.
public interface RouterLifecycleHook extends OrderedExtension { default void beforeAuthSetup(RouterSetup setup) {} default void afterAuthSetup(RouterSetup setup) {} default void afterRouterCreated(Router router) {}}RouterSetup exposes router() and security() (a SecuritySchemeRegistry).
OperationHandlerContributor
Section titled “OperationHandlerContributor”Appends a handler to one operation’s chain during route registration.
public interface OperationHandlerContributor extends OrderedExtension { @Override int priority(); // abstract: must be declared void contribute(OperationRegistrationContext context);}@Provides@IntoSetstatic OperationHandlerContributor quotaGate(QuotaService quotas) { return new OperationHandlerContributor() { @Override public int priority() { return 200; } @Override public void contribute(OperationRegistrationContext context) { String operationId = context.operationId(); context.route().addHandler(rc -> { if (quotas.allows(operationId)) { rc.next(); } else { rc.fail(429); } }); } };}Framework contributors occupy these priorities; choose a band that does not collide, and place any contributor that reads an authenticated identity above 100:
| Priority | Contributor | Module |
|---|---|---|
| 40 | action-gate authentication | vertique-rest-security |
| 50 | JWT claims validation | vertique-rest-auth-jwt |
| 80 | identity resolution | vertique-rest-security |
| 100 | authorization | vertique-rest-security |
| 360 | server-span enrichment | vertique-opentelemetry-rest |
Route identity for the completion event is recorded by the framework before authentication, not by a contributor (see Request completion above).
The terminal operation invoker is appended after every contributor, so a contributor always runs before the resource method.
OperationRegistrationContext carries operationId(), securityPolicy(),
requiredAction() (Optional<ActionRef>), operation() (RestOperationDescriptor), and route()
(RouteRegistration, whose addHandler(...) returns itself for chaining).
RestOperationDescriptor.applicationName() is a @Nullable String default method that returns
null. It names the application the operation belongs to or serves, which covers an application
mount’s operations and framework synthetic operations; it is null for every other operation. It is the
one descriptor accessor exempt from the rule that the identity accessors are never null, and existing
RestOperationDescriptor implementations keep compiling without overriding it. operationId() is
unique within its mount; a contributor or interceptor that keys state per operation and must not
depend on how a runtime treats ids across mounts includes the application name in the key.
Contributors may also run for framework-owned synthetic operations — routes installed outside normal
resource-method discovery. A synthetic route runs the same contributor chain, with the same inputs,
as an equally-secured resource route: the same contributors, in the same order, with the same
effective security policy. A synthetic operation’s id lives in the reserved apidocs: namespace, its
operation() descriptor reports a literal route template with no consumed or produced media types,
and the descriptor’s annotations are the synthetic security annotations its effective policy was
built from — so a contributor that reads annotations sees exactly what an equally annotated resource
method would show. No signature, default, or behavior of this interface changes for a synthetic
operation.
A synthetic route renders a failure from its status alone (ctx.fail(status) or an
HttpException), without the application’s exception mapping or interceptors; a contributor
rejecting a synthetic operation fails with an explicit 4xx or 5xx status.
RequestInterceptor, OperationInterceptor, ErrorInterceptor
Section titled “RequestInterceptor, OperationInterceptor, ErrorInterceptor”Three pipelines with distinct scopes. Every callback has a default, so implement only what you need.
public interface RequestInterceptor extends OrderedExtension { String ORIGINAL_ERROR_KEY = "dev.vertique.rest.originalError";
default void onRequest(RoutingContext rc) {} default void onError(RoutingContext rc, Throwable error) {} default void onSerialize(RoutingContext rc, Response response, SerializedBody body) {} default void afterResponse(RoutingContext rc, Response response) {} default Future<Void> beforeRequest(RoutingContext rc) { return Future.succeededFuture(); } default Future<Response> transformResponse(RoutingContext rc, Response response) { … }}
public interface OperationInterceptor extends OrderedExtension { default void onOperation(OperationContext ctx) {} default void onSuccess(OperationContext ctx, Object result) {} default void onError(OperationContext ctx, Throwable cause) {} default Future<OperationContext> beforeOperation(OperationContext ctx) { … } default Future<Object> afterOperation(OperationContext ctx, Object result) { … } default Future<Object> recoverOperation(OperationContext ctx, Throwable cause) { … }}
public interface ErrorInterceptor extends OrderedExtension { default Future<Throwable> beforeMapping(RoutingContext rc, Throwable throwable) { … } default Future<Response> afterMapping(RoutingContext rc, Response response) { … }}OperationContext is immutable — operationId(), routingContext(), methodAnnotations(),
classAnnotations(), attributes(), operation(), the typed lookups methodAnnotation(Class) /
classAnnotation(Class), and withAttribute(String, Object), which returns a new context that
carries every component, operation included. Return that new instance from beforeOperation or the
attribute is lost.
operation() is the operation’s RestOperationDescriptor, the same instance the operation’s
OperationHandlerContributors received as OperationRegistrationContext.operation() when the
JAX-RS adapter registers the route. Read the
application name from ctx.operation().applicationName(). operation() is null on a context built
without a descriptor, such as one constructed with the five-argument constructor, so a key that uses it
should handle null when the interceptor can see such contexts. An interceptor that keys state per
operation includes the application name, so operations of different applications never share an
entry. On a mount that belongs to no declared application the application name is null, so the
key reads null:<operationId>, and the cross-mount operation-id uniqueness check runs only when
applications are declared:
public Future<Object> recoverOperation(OperationContext ctx, Throwable cause) { return cache.get(ctx.operation().applicationName() + ":" + ctx.operation().operationId()) .<Object>map(cached -> cached) .orElse(Future.failedFuture(cause));}Each interceptor’s beforeOperation receives the context the previous one returned. The chain
remembers the operation() of the context it starts with. After each beforeOperation, when the
returned context’s operation() is not that same instance (compared by reference), the chain replaces
the returned context with a copy that carries the original operation and keeps every other component,
so every later interceptor and every later hook (onOperation, afterOperation, onSuccess,
onError, recoverOperation) observes the registration-time operation. The chain restores only the
operation component; the operationId() of a context an interceptor rebuilt is left as returned, so
read the operation’s identity from ctx.operation(). A context an interceptor builds with the
five-argument constructor carries a null operation, and the chain puts the operation back.
OperationContext gains operation as a nullable component appended after attributes. The
five-argument constructor OperationContext(operationId, routingContext, methodAnnotations, classAnnotations, attributes) remains and supplies null, so code that constructs an
OperationContext with five arguments keeps compiling. A record pattern that deconstructs
OperationContext into five components no longer compiles against the six-component record; read the
components through the accessors instead.
recoverOperation defaults to re-failing; returning a succeeded future turns a failure into a
result. ORIGINAL_ERROR_KEY names the routing-context entry that carries the pre-mapping throwable.
The Vert.x-originated failure status a router-level failure handler records is not part of this
contract — it is an internal handoff owned by dev.vertique:vertique-rest-jaxrs; observe the
resulting status on the Response instead.
RequestBodyDecoder and ResponseBodyEncoder
Section titled “RequestBodyDecoder and ResponseBodyEncoder”Content-type-driven body handling. The framework walks each set in OrderedExtension order and uses
the first implementation whose canDecode / canEncode returns true.
public interface RequestBodyDecoder extends OrderedExtension { boolean canDecode(Class<?> targetType, String contentType); default Object decode(RoutingContext ctx, RequestValue body, Class<?> targetType, Type genericType) { … } default Object decode(RoutingContext ctx, RequestValue body, Class<?> targetType) { … }}
public interface ResponseBodyEncoder extends OrderedExtension { boolean canEncode(Class<?> entityType, String contentType); SerializedBody encode(RoutingContext ctx, Response response, Object entity);}Both decode overloads have defaults that delegate to each other — override exactly one, or
every call throws UnsupportedOperationException. Override the four-argument form when the target is
generic (List<Item>); the three-argument form otherwise.
Framework implementations sit at priorities 999–1100 (SseBodyEncoder 999, binary/text/form/stream
1000, JSON 1100). Application implementations default to priority 0 and therefore win by default —
give one a priority above 1100 to act as a fallback instead.
RequestValue is the neutral accessor for the bound value (getString(), getJsonObject(),
getBuffer(), get(), typed defaults, and isX() predicates). SerializedBody is sealed over
BufferedBody(Buffer, String contentType, Long contentLength) and
StreamingBody(ReadStream<Buffer>, String contentType, Long contentLength).
@Provides@IntoSetstatic RequestBodyDecoder csvDecoder() { return new RequestBodyDecoder() { @Override public boolean canDecode(Class<?> targetType, String contentType) { return contentType != null && contentType.startsWith("text/csv"); } @Override public Object decode(RoutingContext ctx, RequestValue body, Class<?> targetType) { return CsvReader.read(body.getBuffer(), targetType); } };}A decoder that materializes a DTO from a structured intermediate can route it through the
framework’s canonicalization/sanitization traversal by injecting the optional
dev.vertique.input.processing.InputObjectProcessor (from the
dev.vertique:vertique-input-processing artifact) and calling
processInput(Object input, Type targetType, EffectiveInputPolicies policies, InputLocation location, InputFieldNameResolver nameResolver) before final binding. The binding is
@BindsOptionalOf; it resolves only when dev.vertique:vertique-sanitization is on the graph.
The trailing dev.vertique.core.sanitization.InputFieldNameResolver is the decoder’s own choice,
and there is no overload that omits it. Pass InputFieldNameResolver.IDENTITY when the
intermediate’s keys are already Java property names — including any call that processes a bare
string, where no object’s fields could be renamed. Pass a codec-backed projection when the keys are
wire names the codec renames while binding, such as JacksonFieldNameResolver from
dev.vertique:vertique-json for a decoder that materializes through an ObjectMapper; build it
from the same mapper the decoder will bind with. Choosing IDENTITY where the keys are wire names
does not fail the request — it looks each key up under a name the target type never declares, so
the declared chains for those fields do not run. The projection’s semantics are documented in the
vertique-input-processing reference.
ResponseProducer and ResponseProducerBinding
Section titled “ResponseProducer and ResponseProducerBinding”Maps a domain return type to a jakarta.ws.rs.core.Response before serialization.
@FunctionalInterfacepublic interface ResponseProducer<T> { Response produce(RoutingContext ctx, T result);}
public record ResponseProducerBinding<T>(Class<T> type, ResponseProducer<T> producer) {}@Provides@IntoSetstatic ResponseProducerBinding<?> createdProducer() { return new ResponseProducerBinding<>( Created.class, (ctx, created) -> Response.created(URI.create(created.location())).entity(created.body()).build());}The produced Response still passes through transformResponse interceptors.
ResponseSerializer
Section titled “ResponseSerializer”public interface ResponseSerializer { Future<Void> serialize(RoutingContext ctx, Response response);}The terminal wire-completion contract. The implementation ships in vertique-rest-jaxrs; replace it
only to take over serialization orchestration entirely. It is called on the request’s event-loop
context after the status and headers are written and after transformResponse hooks have run —
empty-body and bare fallback paths bypass it. An implementation must not block the calling thread.
Completion is dual-channel, and the two channels mean different things:
| Signal | Meaning | Caller behavior |
|---|---|---|
| synchronous throw | nothing was written or ended — an encode-time failure | may retry once against the same response head (fail-open) |
| returned future succeeds | the response was fully written and ended | done |
| returned future fails | the wire write failed after handoff; zero or more bytes may already be on the wire | never retried; the caller owns terminal cleanup |
The returned future may complete on any thread — do not assume context affinity; the framework
pipeline redispatches handling back onto the request context. A StreamingBody must be piped, never
buffered, and the response must not be ended on pipe failure.
RestContextResolver
Section titled “RestContextResolver”The single resolution path for @Context-injectable resource parameters. Register one to make a new
type injectable.
public interface RestContextResolver extends OrderedExtension { int PRIORITY_ROUTING_CONTEXT = 100; int PRIORITY_JAXRS_SECURITY_CONTEXT = 110; int PRIORITY_CONTEXT_HOLDER = 120;
<T> Optional<T> resolve(Class<T> type, RoutingContext ctx);}Built-in resolvers occupy those three priorities: RoutingContext and subtypes at 100,
jakarta.ws.rs.core.SecurityContext at 110 (empty when no security module is installed), and any
ContextValue subtype at 120. A resolver must return Optional.empty() for types it does not own —
the first non-empty result wins.
An application resolver takes the default phase and priority 0, so it already runs ahead of
every built-in and may shadow a framework-provided value for the same type. Give it a priority
above 120 to act as a fallback instead.
Contract: resolve is a pure read. An implementation must not create, mutate,
enrich, replace, or propagate context as a side effect — it observes request state and reports back.
RestContextResolution is the injectable coordinator: resolve(Class, RoutingContext) returns an
Optional, and require(Class, RoutingContext, String resourceClass, String methodName) throws
RestContextUnavailableException when nothing matches. The chain is sorted once at construction.
ParamConverter and ParamConverterBinding
Section titled “ParamConverter and ParamConverterBinding”Symmetric string conversion for @PathParam, @QueryParam, @HeaderParam, @CookieParam, and
@FormParam — the same converter parses inbound values and serializes outbound ones for the REST
client.
public interface ParamConverter<T> { T fromString(String value); String toString(T value);}
public record ParamConverterBinding<T>(Class<T> targetType, ParamConverter<T> converter) {}@Provides@IntoSetstatic ParamConverterBinding<?> isbnConverter() { return new ParamConverterBinding<>(Isbn.class, new ParamConverter<Isbn>() { @Override public Isbn fromString(String value) { return Isbn.parse(value); } @Override public String toString(Isbn value) { return value.normalized(); } });}Implementations must be stateless and thread-safe — one instance serves every request. Built-in
converters cover String, every primitive and its box, BigInteger, BigDecimal, UUID, URI,
and the java.time types Instant, LocalDate, LocalTime, LocalDateTime, OffsetDateTime,
OffsetTime, ZonedDateTime, Duration, Period, Year, YearMonth, MonthDay, ZoneId, and
ZoneOffset. Any enum is converted by name automatically with no registration.
A binding for a built-in type replaces it. Two bindings for the same target type fail component
construction with IllegalStateException.
The native registry itself is ParamConverterRegistry. ParamConverterRegistry.of(Set) combines
the built-ins above with the contributed bindings — an application binding wins over a built-in for
the same target type — and find(Class) resolves exact class first, then synthesizes an enum
converter, then returns Optional.empty(). It is context-free and thread-safe, and it is what a
framework module calls when it needs the same conversion table outside a request.
ParamConversionResolver tries the native registry first, then any contributed
jakarta.ws.rs.ext.ParamConverterProvider (also a @Multibinds set), ordered by @Priority
ascending. Its ConversionContext(String paramName, ParamSource source, Class<?> rawType, @Nullable Type genericType, @Nullable Class<?> componentType, Supplier<Annotation[]> annotationsLazy)
carries dev.vertique.rest.core.convert.ParamSource, whose five constants are PATH, QUERY,
HEADER, COOKIE, and FORM — the string-ish transport kinds. Do not confuse it with the
same-named but unrelated parameter-source enums in the JAX-RS and REST-client modules.
Non-null converter-result contract. fromString never returns null for a present transport
string. A native or JAX-RS converter that returns null for that present value fails closed with
ParamConversionException (400) — the same rule for a submitted value and for a @DefaultValue
string routed through the resolver. Absence of a parameter is a caller concern (empty collection,
null scalar, or applying a default); it is not expressed by a converter returning null. The
exception message names the parameter and target type and never echoes the raw value.
RestRequestCompletedListener, HttpRequestCompletedListener, and RequestCompletionScope
Section titled “RestRequestCompletedListener, HttpRequestCompletedListener, and RequestCompletionScope”RestRequestCompletedListener observes JAX-RS operations, and HttpRequestCompletedListener
observes requests no transport claimed. A request another transport claimed produces neither event.
public interface RestRequestCompletedListener { void onCompleted(RestRequestCompletedEvent event);
default void onCompleted(RestRequestCompletedEvent event, RoutingContext routingContext) { onCompleted(event); }}
public interface HttpRequestCompletedListener { void onCompleted(HttpRequestCompletedEvent event);
default void onCompleted(HttpRequestCompletedEvent event, RoutingContext routingContext) { onCompleted(event); }}
public interface RequestCompletionScope { AutoCloseable open(RoutingContext rc);}The framework calls each listener’s two-argument overload once per request, with the live root
RoutingContext of the request; the default delegates to the one-argument method. Each interface
keeps exactly one abstract method, so a listener that needs only the event stays a lambda over
onCompleted(event), like the one below, and receives each event exactly once. Override the
overload when a listener needs per-request state or the live request. An override that does not
delegate never sees the one-argument call.
The overload runs on the thread that ended the response, which is usually, but not always, the
Vert.x event loop. Its context stays the root one for a request a sub-router serves: the
sub-router’s route handler receives a different RoutingContext wrapper, which shares request(),
response(), and data() with the root. Key per-request state by routingContext.request(),
never by the RoutingContext object. An implementation must not block, write to the response, call
next() or fail(), or keep the context after it returns, and it must keep sensitive values out
of the exceptions it throws, as the isolation rules below explain.
A Mockito mock of either listener does not run the default method, so a mock’s one-argument method
is never called. A test verifies the two-argument call instead, or creates the mock with
CALLS_REAL_METHODS.
@Provides@IntoSetstatic RestRequestCompletedListener requestMetrics(MeterRegistry registry) { return event -> registry.counter( "http.server.requests", "method", event.method(), "route", event.operation().routeTemplate(), "status", Integer.toString(event.statusCode())) .increment();}public record RestRequestCompletedEvent( Instant startTime, Instant endTime, String method, String path, RestOperationDescriptor operation, int statusCode, @Nullable String failureCode, @Nullable String safeFailureMessage, @Nullable String wireFailureCode, @Nullable SecurityContextSnapshot securityContextSnapshot, @Nullable CorrelationContextSnapshot correlationContext, Optional<RequestOrigin> origin, Map<String, Object> safeAttributes) {}
public record HttpRequestCompletedEvent( Instant startTime, Instant endTime, String method, String path, int statusCode, @Nullable String failureCode, @Nullable String safeFailureMessage, @Nullable String wireFailureCode, @Nullable SecurityContextSnapshot securityContextSnapshot, @Nullable CorrelationContextSnapshot correlationContext, Optional<RequestOrigin> origin, Map<String, Object> safeAttributes) {}operation() is never null. As a Stable promise, operation() is the same instance that
OperationHandlerContributors received as OperationRegistrationContext.operation() for the
matched route. State a contributor keyed on its descriptor is therefore matched to the event by
identity. Framework-built descriptors compare by identity, so don’t key maps on descriptor value
equality.
The two compact toString forms differ on purpose, and neither is a parse format:
RestRequestCompletedEventkeeps the record’sRestRequestCompletedEvent[name=value, …]form and renders its operation as<operationId> <routeTemplate>, for examplegetUser /users/{id}, without calling the descriptor’stoString.- A framework-built descriptor, and one from
TestOperationDescriptors(vertique-rest-test), renders as<httpMethod> <routeTemplate> (<operationId>), for exampleGET /users/{id} (getUser).
HttpRequestCompletedEvent uses the default record toString.
Both records evolve under the same rule: new components are only appended, after the last existing one, and each addition keeps the previous-arity constructor. Record-pattern deconstruction binds components by position, so it is outside the compatibility promise.
A RequestCompletionScope wraps the dispatch of either event type — scopes open in iteration order
and close in reverse, which is how tracing modules re-establish a span around emission. Listeners
are unordered: the framework promises no invocation order, and no implementation may depend on
another’s side effects. A listener that throws an Exception, AssertionError,
StackOverflowError or LinkageError is logged and does not stop the remaining listeners or change
the response: an Exception, AssertionError or StackOverflowError is logged at WARN each time,
naming the listener class, the callback and the failure’s class only, and a LinkageError at ERROR
at a limited rate per listener class and callback — the first time, then at most once every five
minutes with the number of failures in between — saying that the listener is unusable and its
notifications are being lost. Any other Error propagates. A scope’s open and close are
contained and reported the same way: when one throws, the listeners are still notified and the other
scopes are still opened and closed.
The logged failure is what keeps the fan-out diagnosable. The exception, including its message and
any cause, is logged at DEBUG, so none of them may carry credentials, tokens, personal data, or raw request
values. An implementation must therefore keep them out of the exceptions it throws. The obligation
is audit-safe by contract rather than by enforcement, exactly as
AuthorizationDecision.safeAttributes() is — the framework does not inspect or scrub what an
implementation throws.
SecuritySchemeHandler and RouteAuthHandler
Section titled “SecuritySchemeHandler and RouteAuthHandler”public interface SecuritySchemeHandler { String schemeName(); void configure(SecuritySchemeRegistry registry); default Optional<SecuritySchemeDescription> openApiDescription() { return Optional.empty(); }}
public interface SecuritySchemeRegistry { void authenticationHandler(AuthenticationHandler handler);}The framework applies the registered handler to every operation whose security requirements name
schemeName(). The registry takes a Vert.x AuthenticationHandler rather than a bare
Handler<RoutingContext> so multiple alternative requirements can be composed into a
ChainAuthHandler.any() — the OR semantics of an OpenAPI security array.
@Provides@IntoSetstatic SecuritySchemeHandler apiKeyScheme(ApiKeyAuthProvider provider) { return new SecuritySchemeHandler() { @Override public String schemeName() { return "apiKey"; } @Override public void configure(SecuritySchemeRegistry registry) { registry.authenticationHandler(APIKeyHandler.create(provider)); } };}RouteAuthHandler (String schemeName(), Handler<RoutingContext> createHandler()) is the
route-level variant; its multibinding is declared by AuthModule in vertique-rest-security.
createHandler() always retains required-authentication semantics. A transport that supports
anonymous callers alongside authenticated callers must select only a handler that explicitly returns
a non-empty createOptionalHandler() result. That handler continues once without changing the user
or authentication evidence when its credentials are absent; it must reject present-but-invalid
credentials through the same verification path as its required handler.
SecurityRuntime, SecurityPolicyResolver, and SecurityPolicyValidator are declared here and
implemented in vertique-rest-security. Bind your own only to replace framework behavior wholesale.
SecurityRuntime.bindCurrent(SecurityContext) returns a ContextHolder.Scope that must be
registered with RequestContextLifecycle.Handle.onClose(...).
SecurityRuntime.clearCurrent() removes any currently bound SecurityContext without restoring a
prior binding — used by trust-boundary clears such as MCP’s no-scheme admit path that must discard
ambient holder state before the transport binds its own identity. Prefer bindCurrent with
lifecycle-owned scopes for ordinary bind/unbind.
Describing a scheme for OpenAPI
Section titled “Describing a scheme for OpenAPI”A handler may override openApiDescription() to describe its scheme; the default returns
Optional.empty(), so no existing handler need change. The description is dormant in this module —
nothing here reads it — until a documentation-rendering module turns it into
components.securitySchemes.
SecuritySchemeDescription is a closed, sealed interface with five kinds, each an immutable final
class built only through its static factories, with with* copies. The description types —
SecuritySchemeDescription, its kinds, OAuthFlows, and OAuthFlow — live in
dev.vertique.rest.core.security.scheme; SecuritySchemeHandler stays in
dev.vertique.rest.core.security:
public sealed interface SecuritySchemeDescription permits Http, ApiKey, OAuth2, OpenIdConnect, MutualTls { Optional<String> description();}
Http.bearer(@Nullable String bearerFormat) // type: http, scheme: bearerHttp.of(String scheme) // type: http, any scheme name
ApiKey.header(String name) // type: apiKey, in: headerApiKey.query(String name) // type: apiKey, in: queryApiKey.cookie(String name) // type: apiKey, in: cookie
OAuth2.of(OAuthFlows flows) // type: oauth2, flows built through OAuthFlows.builder()
OpenIdConnect.of(URI openIdConnectUrl) // type: openIdConnect
MutualTls.of() // type: mutualTLSOAuthFlows.builder() sets each of the four flow kinds — implicit(authorizationUrl, scopes),
password(tokenUrl, scopes), clientCredentials(tokenUrl, scopes), and
authorizationCode(authorizationUrl, tokenUrl, scopes) — with a typed method so a flow carries
exactly the URLs its type uses, plus refreshUrl(URI), applied to every flow set on the builder
whether called before or after it; build() requires at least one flow to have been set.
A description carries only OpenAPI Security Scheme fields — no extension map, no JSON tree. What a handler puts in those fields is its own responsibility; the type itself checks nothing about token claims, granted scopes, or any other business meaning.
Construction rules. Every factory and with* method rejects a null argument with
NullPointerException and a blank string or relative URI with IllegalArgumentException, each
naming the argument. Http.bearer’s bearerFormat is the one nullable argument across every
factory: null yields an empty bearerFormat(), while a blank value is rejected. ApiKey.header
and ApiKey.cookie names must additionally be RFC 9110 tokens — an ASCII letter, a digit, or one of
!#$%&'*+-.^_`|~ — because a header scheme’s name can reach a response’s Vary header;
ApiKey.query names need only be non-blank, since a query parameter reaches no header. Every OAuth
flow URL and the OpenID Connect discovery URL must be absolute; a scope name must be non-blank
though its description may be empty; and OAuthFlows.builder().build() throws
IllegalStateException when no flow was set.
Each kind implements equals, hashCode, and a field-only toString — equality is by value over
every field.
Evolution rule (Stable). New kinds and new optional fields may arrive in later releases; do not
switch exhaustively over the permitted kinds. A new optional field arrives as a new accessor plus a
new with* method; a new kind arrives as a new permitted class; every existing factory keeps its
signature.
ProtocolCorrelationSpec and ProtocolCorrelationContributor
Section titled “ProtocolCorrelationSpec and ProtocolCorrelationContributor”Teach correlation ingress about a protocol-specific header (for example
X-FAPI-Interaction-ID). ProtocolCorrelationSpec is the declarative route: name the header, the
response and propagation modes, whether the value is durable-safe, and how an inbound value is
accepted. ProtocolCorrelationContributor is the imperative escape hatch, resolving a
ProtocolCorrelationRef straight from the HttpServerRequest. Both are @Multibinds sets on
CorrelationIngressModule, which RestCoreModule includes.
CursorCodec
Section titled “CursorCodec”public interface CursorCodec { String encode(String rawCursor); String decode(String opaqueToken) throws InvalidCursorException;}Bind HmacCursorCodec (or your own) as a @Singleton and pass it to CursorPage.of(...) and
CursorPageRequest.decodeCursor(...). It is an ordinary binding, not a multibinding.
Framework seams
Section titled “Framework seams”Eleven public types are named nowhere above because no application uses one — RestContextMessages,
RestContextModule, RestContextTypes, RequestCompletionRecorder, MountCompositionValidator,
SecurityRequirementSet, AuthEnforcementCapability, SecurityPolicyViolation,
RequiresActionResolver, DeferredCredentialRejectionAuthHandler, and
AnnotationSecurityPolicyResolver. They are public because sibling framework modules call them
across package boundaries: the JAX-RS route registrar, the security enforcement modules, the
WebSocket transport, the OpenTelemetry integration, and the annotation processors that emit against
the same vocabulary.
Their Javadoc marks them INTERNAL and they sit outside this module’s compatibility promise. What this module promises an application is the extension points above, the configuration keys below, and the documented request and failure behavior.
Configuration
Section titled “Configuration”Three top-level sections are parsed by RestCoreModule — http, cors, and jaxrs — plus
correlation.ingress by CorrelationIngressModule. Every key is optional; omitted keys take the
default below. Unknown keys are ignored except under jaxrs.defaultHeaders, where they become
custom response headers, and except under jaxrs.security and jaxrs.applications. The
jaxrs.applications section is parsed strictly by vertique-rest-jaxrs: a case variant of
applications at the jaxrs level (such as Applications), a non-object section, a non-object
entry, or an entry key other than openapiPath fails startup, as does a blank
openapiPath (empty or whitespace only; an absent or null value is accepted). An entry whose name
matches no declared application also fails startup, reported with the application composition
violations rather than by the section parse. The jaxrs.security
exception is a deliberate narrowing of this Stable module’s “unknown keys are ignored” rule,
limited to these reserved names: a non-object jaxrs.security value (including null), an unknown key under it, and — at the
jaxrs level — a case variant of security or a misplaced requireExplicitPolicy (in any case)
each fail startup with a ConfigurationException naming the offending keys, sorted, never their
values.
The keys are the contract. What this module freezes is the key names below, their types,
defaults, and constraints. The record types the parser binds them to — HttpConfig, SslConfig,
SseConfig, CorsConfig, JaxRsConfig, JaxRsSecurityConfig, DefaultHeadersConfig, and
CorrelationIngressConfig — are an implementation detail of that parse. An application writes
configuration, not those types, and their shape can change while the keys stay as documented.
| Key | Default | Constraint / notes |
|---|---|---|
http.port |
8080 |
|
http.host |
"0.0.0.0" |
|
http.maxBodySize |
2097152 |
total request-body bytes — exceeding it returns 413 |
http.maxMultipartBodySizeBytes |
2097152 |
must be positive; pre-auth multipart/form-data admission ceiling — BodyHandler uses min(maxBodySize, maxMultipartBodySizeBytes) (just maxMultipartBodySizeBytes when maxBodySize is the -1 unlimited sentinel, which leaves non-multipart unlimited) and returns 413 when exceeded (Content-Length early reject before spool when present). Raise maxBodySize for large non-multipart payloads without widening multipart spool by keeping this tight. Residual: when http.decompressionSupported is true, compressed-request expansion is not yet separately bounded beyond this BodyHandler limit |
http.uploadsDirectory |
"file-uploads" |
must be non-blank; multipart spool directory |
http.compressionSupported |
false |
gzip/deflate responses |
http.compressionLevel |
6 |
1–9 |
http.decompressionSupported |
false |
gzip/deflate request bodies |
http.maxHeaderSize |
8192 |
bytes, all headers combined |
http.maxInitialLineLength |
4096 |
bytes |
http.handle100ContinueAutomatically |
false |
|
http.idleTimeoutSeconds |
0 |
0 disables |
http.readIdleTimeoutSeconds |
0 |
0 disables |
http.writeIdleTimeoutSeconds |
0 |
0 disables |
http.tcpKeepAlive |
false |
|
http.acceptBacklog |
-1 |
-1 uses the OS default |
http.useProxyProtocol |
false |
read the real client IP from an upstream proxy |
http.maxFormAttributeSize |
8192 |
bytes, per URL-encoded form value |
http.maxFormFields |
256 |
form parts per request; one shared limit across multipart file parts, multipart text parts, and URL-encoded attributes |
http.ssl
Section titled “http.ssl”When enabled is false, every other key in this section is ignored.
| Key | Default | Constraint / notes |
|---|---|---|
http.ssl.enabled |
false |
|
http.ssl.keyStorePath |
(none) | keystore file, or the PEM private key |
http.ssl.keyStorePassword |
(none) | |
http.ssl.keyStoreType |
"JKS" |
JKS, PKCS12, or PEM; any other value falls back to JKS handling |
http.ssl.certPath |
(none) | PEM certificate; defaults to keyStorePath for a combined file |
http.ssl.trustStorePath |
(none) | client-certificate validation (mTLS) |
http.ssl.trustStorePassword |
(none) | |
http.ssl.trustStoreType |
"JKS" |
JKS, PKCS12, or PEM |
http.ssl.clientAuth |
"NONE" |
NONE, REQUEST, or REQUIRED; anything else fails startup |
http.ssl.enabledProtocols |
["TLSv1.2","TLSv1.3"] |
|
http.ssl.useAlpn |
false |
required for HTTP/2 |
http.ssl.sni |
false |
When enabled is false (the default) no CORS handler is installed and every other key is ignored.
| Key | Default | Constraint / notes |
|---|---|---|
cors.enabled |
false |
|
cors.origins |
["*"] |
exact origins or "*" |
cors.allowedMethods |
["GET","POST","PUT","DELETE","PATCH","OPTIONS"] |
must be valid HTTP method names |
cors.allowedHeaders |
["*"] |
|
cors.exposedHeaders |
[] |
|
cors.allowCredentials |
false |
|
cors.maxAge |
3600 |
seconds a browser may cache a preflight |
| Key | Default | Constraint / notes |
|---|---|---|
jaxrs.basePath |
"/*" |
mount path of the JAX-RS sub-router; not applied when one or more @RestApplication declarations are present — each is mounted at its own @RestApplication.path instead |
jaxrs.openapiPath |
"openapi.json" |
classpath spec; used by the opt-in openapi-contract strategy, and the shared global contract location for every application that sets neither @RestApplication.openapiPath nor jaxrs.applications.<name>.openapiPath; vertique-rest-openapi-docs refuses to document an application whose strategy resolves operations from that shared global contract |
jaxrs.applications |
(none) | per-application settings, keyed by application name; parsed strictly by vertique-rest-jaxrs; unknown keys fail startup — see that module’s reference |
jaxrs.mediaTypeValidation |
"WARN" |
WARN, STRICT (fails startup on the first mismatch), or OFF |
jaxrs.validationStrategy |
"web-validation" |
must match a registered strategy id — built-ins are web-validation, none, openapi-contract; an unknown id fails startup when any JAX-RS mount has resources; a mount with no resources never selects a strategy |
jaxrs.validationMode |
"aggregate" |
aggregate or failFast |
jaxrs.validationPatternMaxChars |
4096 |
at least 1, else startup fails; the most UTF-16 code units one string value or object key may have when it reaches a pattern, patternProperties, or pattern-bearing propertyNames position, or an idn-hostname, idn-email, or regex format, under the web-validation strategy — a longer one is rejected with 400 before that check runs |
jaxrs.validationPatternMaxTotalChars |
262144 |
at least 1 and no smaller than jaxrs.validationPatternMaxChars, else startup fails; the most UTF-16 code units the strings and keys reaching those positions may add up to in one request — the request is rejected with 400 once the total exceeds it |
jaxrs.fileContentVerifierDeadlineMs |
5000 |
must be > 0, else startup fails; per-invocation wait deadline for each bound FileContentVerifier future under web-validation — timeout fails closed (500) and stops the sequential chain; does not cancel verifier-owned work (see vertique-rest-validation) |
jaxrs.autoEtag |
false |
attach a weak ETag derived from the serialized body when none is set |
jaxrs.jsonProfile |
(none) | must name a registered JSON mapper profile; resolution is method @JsonProfile → class @JsonProfile → this key → json.jsonProfile → the vertique floor |
jaxrs.security.requireExplicitPolicy |
false |
boolean; when true, every JAX-RS operation must declare an explicit security policy, else startup fails — details in the vertique-rest-jaxrs reference |
When jaxrs.security.requireExplicitPolicy is true, RestCoreModule logs one INFO line:
jaxrs.security.requireExplicitPolicy is enabled: explicit security policies are required for every JAX-RS operation. The default false logs nothing new.
jaxrs.security.requireExplicitPolicy is read only from the nested jaxrs → security object
shown above. A dotted key placed directly under jaxrs — for example
{"security.requireExplicitPolicy": true} — is an ordinary unknown key, not one of the reserved
names above, and is silently ignored; it never reaches the opt-in. A flat
-Djaxrs.security.requireExplicitPolicy system property or the equivalent environment variable is
not read directly either, because neither source expands a dotted key into nested JSON; such a value
reaches the opt-in only through a ${...} placeholder written at the nested requireExplicitPolicy
position, resolved as described in the vertique-config-core reference’s placeholder resolution
chain. The INFO line above is the only confirmation that the opt-in resolved to true.
jaxrs.validationPatternMaxChars and jaxrs.validationPatternMaxTotalChars guard
regular-expression evaluation against denial of service, and their defaults reject input: a request
whose strings or keys at those positions exceed either limit receives a 400 even when its schema
would accept the values. An application that must accept longer input there raises the limits.
Only positions carrying a pattern, patternProperties, or one of the three named formats count,
so a body with none of them — dates, timestamps, or identifiers alone — is never rejected by these
limits. Both values are validated where RestCoreModule provides the jaxrs configuration, so an
invalid value fails startup whichever validation strategy is selected. The positions, the rejection
details, and the formats left unbounded are described in the vertique-rest-validation reference.
jaxrs.fileContentVerifierDeadlineMs bounds each FileContentVerifier wait under web-validation
(per invocation, default 5000 ms, must be > 0). A hanging verifier fails closed with 500 within
that bound; the framework does not cancel verifier-owned scanner or client work. The cancellation
and resource-release contract for implementors is documented in the vertique-rest-validation
reference. The value is validated where RestCoreModule provides the jaxrs configuration.
jaxrs.defaultHeaders
Section titled “jaxrs.defaultHeaders”Applied to every response. Setting a known key to null or an empty string suppresses that header.
Any key that is not one of the five below is emitted verbatim as a custom header, and a custom entry
whose name collides with a known header wins.
| Key | Header | Default |
|---|---|---|
cacheControl |
Cache-Control |
"no-store" |
contentTypeOptions |
X-Content-Type-Options |
"nosniff" |
frameOptions |
X-Frame-Options |
"DENY" |
strictTransportSecurity |
Strict-Transport-Security |
(not emitted) |
referrerPolicy |
Referrer-Policy |
(not emitted) |
Custom entries are applied to every response too, including responses of operations that restrict
callers and their problem responses. A targeted caching header such as CDN-Cache-Control,
Surrogate-Control, X-Accel-Expires, or Expires set here therefore reaches protected responses,
and a CDN or proxy that obeys it can store them. Do not set such headers here while the application
serves restricted operations or protected documents (the vertique-rest-openapi-docs reference
covers protected documents).
jaxrs.sse
Section titled “jaxrs.sse”| Key | Default | Constraint / notes |
|---|---|---|
jaxrs.sse.keepAliveEnabled |
true |
emit a periodic keep-alive comment on idle connections |
jaxrs.sse.keepAliveIntervalMs |
15000 |
|
jaxrs.sse.defaultBufferSize |
256 |
per-channel buffer when no SseChannelOptions are supplied |
jaxrs.sse.defaultOverflowPolicy |
"FAIL" |
FAIL or DROP_OLDEST |
correlation.ingress
Section titled “correlation.ingress”| Key | Default | Constraint / notes |
|---|---|---|
correlation.ingress.requestIdHeader |
"X-Request-Id" |
must be a valid HTTP header token |
correlation.ingress.correlationIdHeader |
"X-Correlation-Id" |
must be a valid HTTP header token |
correlation.ingress.causationIdHeader |
"X-Causation-Id" |
must be a valid HTTP header token |
correlation.ingress.echoRequestId |
true |
emit the request id as a response header |
correlation.ingress.echoCorrelationId |
false |
emit the correlation id as a response header |
correlation.ingress.parseCausationId |
false |
accept an inbound causation id |
correlation.ingress.invalidValuePolicy |
"REPLACE_WITH_GENERATED" |
REPLACE_WITH_GENERATED or REJECT (returns 400) |
Example
Section titled “Example”{ "http": { "port": 8443, "maxBodySize": 4194304, "maxMultipartBodySizeBytes": 2097152, "idleTimeoutSeconds": 30, "ssl": { "enabled": true, "keyStorePath": "/etc/certs/server.p12", "keyStorePassword": "changeit", "keyStoreType": "PKCS12", "useAlpn": true } }, "cors": { "enabled": true, "origins": ["https://app.example.com"], "allowCredentials": true, "allowedHeaders": ["Authorization", "Content-Type"] }, "jaxrs": { "basePath": "/api/*", "validationMode": "failFast", "defaultHeaders": { "strictTransportSecurity": "max-age=31536000; includeSubDomains", "X-Service-Name": "orders" }, "sse": { "defaultBufferSize": 512, "defaultOverflowPolicy": "DROP_OLDEST" } }, "correlation": { "ingress": { "echoCorrelationId": true, "invalidValuePolicy": "REJECT" } }}Failures, Constraints, and Common Mistakes
Section titled “Failures, Constraints, and Common Mistakes”Startup failures
Section titled “Startup failures”RestConfigurationException extends dev.vertique.core.exception.ConfigurationException and is the
root of this module’s wiring failures.
| Failure | Cause |
|---|---|
IllegalStateException failing the start promise |
one or more invalid mount paths, reported as a single aggregated message |
RestConfigurationException |
invalid ssl.clientAuth, blank http.uploadsDirectory, non-positive http.maxMultipartBodySizeBytes, an unsupported security declaration shape, an unknown jaxrs.validationStrategy |
ConfigurationException |
an invalid pattern-input limit; the per-string limit is checked first, with the message jaxrs.validationPatternMaxChars must be at least 1, then the total, with jaxrs.validationPatternMaxTotalChars must be at least 1 and no smaller than the per-string pattern limit; or a non-positive jaxrs.fileContentVerifierDeadlineMs, with the message jaxrs.fileContentVerifierDeadlineMs must be > 0; none of these messages echo a configured value |
SecurityPolicyViolationException |
security-policy validation found violations; violations() lists each with its operationId and ViolationType |
IllegalStateException at component construction |
two ParamConverterBindings claim the same target type |
RestContextUnavailableException |
a declared @Context parameter has no resolver; carries type(), resourceClass(), methodName() |
Mount paths must be non-blank, start with /, end with /*, and contain no //, ?, or #.
Overlapping or duplicate mount paths only warn — the first-mounted router wins and the shadowed
one is silently unreachable, so check startup logs when a route 404s unexpectedly.
Unsupported security shapes are rejected by EffectiveSecurityPolicy.enforceSupportedShape: a
multi-scheme AND requirement, scopes declared on an OR alternative, and scopes declared through both
@Authorized and @SecurityRequirement.
Request-time failures
Section titled “Request-time failures”| Exception | Extends | Typical mapping |
|---|---|---|
RestValidationException |
ValidationException |
400 with a ValidationProblemDetail body |
ParamConversionException |
ValidationException |
400; carries paramName(), source(), targetType(). A converter returning null for a present value, or a null or wrong-typed collection element, raises it. The message omits the submitted value. |
InvalidCursorException |
ValidationException |
400 — always the same opaque "Invalid cursor" message, whether tampered, unknown-key, or expired |
ParamConverterNotFoundException |
TechnicalException |
500 — a wiring gap, not a client error |
Common mistakes
Section titled “Common mistakes”- Registering your own end handler for cleanup.
ctx.addEndHandler(...)for scope teardown breaks the reverse-order guarantee that keeps bound values readable. UseRequestContextLifecycle.Handle.onClose(...)/afterClose(...). - Calling
response().endHandler/exceptionHandler/closeHandler. Those three setters are single-slot: last writer wins. Vert.x Web installs its own handlers there the first time anything callsctx.addEndHandler— whichRequestContextLifecycledoes for every request — so a later call silently replaces them. On that exit path no routing-context end handler fires: the completion event (and with it metrics and audit),Handle.closeAll(), and every otheraddEndHandlerregistration are skipped. Observe disconnects, resets, and normal ends throughctx.addEndHandler(...)(multicast), never through the response setters. Framework code that needs disconnect notification (SSE, MCP settlement) already follows this rule; application code must too. - Registering on the
Handleafter completion.onClose,afterClose, andbindMdcthrowIllegalStateExceptiononce the lifecycle has closed. Leaks are loud, not silent. - Giving a middleware
APIscope on a non-JAX-RS mount. It is dropped without warning. Only the JAX-RS mount honorsMiddlewareScope.API. - Forgetting
priority().MiddlewareandOperationHandlerContributorre-declare it as abstract; there is no default to inherit. - Assuming
@Authorized(scopes = {"a", "b"})means any-of.matchAlldefaults totrue; that declaration requires both scopes. - Overriding both
RequestBodyDecoder.decodeoverloads’ defaults with neither. Implement exactly one or every call throwsUnsupportedOperationException. - Expecting an application decoder or encoder to be a fallback. Application implementations
default to priority
0and therefore precede the framework’s 999–1100 band. Raise the priority above 1100 to sit behind them. - Mutating
OperationContextin place.withAttribute(...)returns a new instance; the original is unchanged. - Keying per-operation state on
operationId()alone. The id is unique within one mount; addctx.operation().applicationName()when the interceptor serves several applications. - Destructuring
MountMetaorOperationContextwith a record pattern. Both records gained a trailing component; use the accessors. - Putting sensitive evidence in
RoutingContext.data(). That map is keyed by public constants and is enumerable and writable by every component sharing the context. - Expecting a completion event for a successful protocol upgrade. There is none; a failed upgrade does emit one.
- Persisting a
MediaType.parse(...)result unchecked. It returnsnullfor malformed input instead of throwing, as doesAcceptNegotiator.negotiate(...)when nothing matches.
Dependencies
Section titled “Dependencies”| Dependency | Why |
|---|---|
dev.vertique:vertique-core |
OrderedExtension ordering contract, exception hierarchy, ConfigParser, ContextHolder, JSON mapper profiles |
dev.vertique:vertique-context |
ContextValues — the substrate the built-in @Context resolver reads |
dev.vertique:vertique-correlation |
correlation context, header validation, and the MDC key vocabulary used by ingress |
dev.vertique:vertique-logging |
MDCContexts scopes bound through RequestContextLifecycle.Handle.bindMdc |
dev.vertique:vertique-security-core |
SecurityContext, its snapshot, RequestOrigin, and ActionRef authorization references |
io.vertx:vertx-web |
Router, RoutingContext, AuthenticationHandler, CorsHandler — and, transitively, Vert.x core |
com.google.dagger:dagger |
@Module / @Multibinds declarations for every extension set |
jakarta.inject:jakarta.inject-api |
@Inject / @Singleton on framework components |
jakarta.ws.rs:jakarta.ws.rs-api |
Response, EntityTag, ExceptionMapper, ParamConverterProvider, parameter annotations |
jakarta.annotation:jakarta.annotation-api |
@Nullable on API signatures |
org.projectlombok:lombok |
provided scope — builders and accessors on the config and problem-detail types; not a runtime dependency |
No OpenAPI artifact is declared, by design: route registration is expressed through RouterSetup,
RouteRegistration, SecuritySchemeRegistry, and RestOperationDescriptor so extensions compiled
against this module stay independent of any contract-validation implementation.