JSON Schema
View MarkdownStatus: Stable Package:
dev.vertique.json.schemaArtifact:vertique-json-schemaDepends on: core API reference: javadoc.io
Framework-support schema generation for sibling Vertique modules. It produces deterministic,
annotation-driven Draft 2020-12 JSON Schema documents from resolved Java Type values (Victools
configured with the Jackson, Jakarta Validation, and Swagger 2 modules). It is transport-neutral and
has no dependency on REST, MCP, Vert.x Web, vertx-json-schema, Dagger, Micrometer, or OpenTelemetry.
Applications do not use this artifact directly. Install vertique-rest-validation
(RestValidationModule), vertique-rest-openapi-docs, or vertique-mcp-server instead. Every
public type in dev.vertique.json.schema is an INTERNAL framework seam — outside the application
maturity promise — even though this module’s Status is Stable for the generation behavior those
consumers rely on.
This module is not a schema registry, a validation engine, or a general-purpose schema DSL. It produces one canonical document per call; framework consumers own caching, validator compilation, and runtime value validation.
When To Use It
Section titled “When To Use It”Do not add this dependency from application code. Use it only when authoring or extending a
Vertique framework module that must synthesize JSON Schema from Jackson / Jakarta Validation /
Swagger annotations (today: rest-validation, rest-openapi-docs, mcp-server). Application
projects that need request validation install RestValidationModule; projects that need published
OpenAPI or MCP tool schemas install those modules.
Core Concepts
Section titled “Core Concepts”Three construction modes
Section titled “Three construction modes”AnnotationJsonSchemaGenerator is constructed through exactly one of three static factories, each
selecting how Victools discovers Jackson properties and which profile-declared schema-type
overrides apply:
withVictoolsDefaults()— Victools’ own default mapper, with no profile override applied. This is the mode a consumer without a resolvedJsonMapperProfileuses.forInputProfile(JsonMapperProfile)— property discovery and external property names use the input-direction Jackson introspection ofprofile.mapper(); mapper mix-ins, explicit names, naming strategies, and write-only/read-only access are honored, so read-only properties are not advertised as accepted input; only the profile’sINPUT- andBOTH-direction schema-type overrides apply.forInputProfile(JsonMapperProfile, jakarta.validation.Validator)— identical toforInputProfile(JsonMapperProfile), except that value-schema constraints are additionally sourced from Bean Validation metadata — the annotation walk still runs first, as the floor every generation mode shares; the metadata source only supplements or, for a bounded set of shapes, corrects it. See “Constraint sources” below. Passnull(or use the single-argument overload) when noValidatoris available; anullvalidator is exactly the single-argument overload’s behavior, not a degraded mode.forOutputProfile(JsonMapperProfile)— property discovery and external property names use the output-direction Jackson introspection ofprofile.mapper(); the same mapper metadata applies, so write-only properties are not advertised as emitted output; only the profile’sOUTPUT- andBOTH-direction schema-type overrides apply. There is no validator-accepting overload for this direction: constraint sourcing applies to input generation only.
All three modes install the Jackson module, the Jakarta Validation module
(NOT_NULLABLE_FIELD_IS_REQUIRED, INCLUDE_PATTERN_EXPRESSIONS), and the Swagger 2 module —
unconditionally, including a validator-backed input generator. The Jakarta Validation module is
the floor: whatever it renders for a scoped field or getter is rendered whether or not a Validator
is supplied, so a document generated with a validator still renders every keyword a document
generated without one would have. A supplied Validator only ever supplements that floor — see
“Constraint sources” below — it never disables or replaces it. The one documented exception is
several @Pattern constraints in the default group on one member (see “Rendering” below): the
annotation walk cannot see more than one of them at all, so a supplied Validator there changes what
is rendered rather than only adding to or correcting it.
Constraint sources: the floor, and the Bean Validation supplement
Section titled “Constraint sources: the floor, and the Bean Validation supplement”The input direction’s value-schema constraints (minLength, maximum, pattern, required, …)
always start from a floor that is present whether or not a Validator is supplied, and gain a
supplement on top when one is:
-
The floor. A field or getter with a Victools member scope is described by Victools’ own Jackson and Jakarta Validation modules, unconditionally. A creator parameter, setter, or builder method has no such scope; its constraints are read directly from Jackson’s merged annotation map (which already carries the same-named field’s and getter’s annotations — Jackson’s own statement that they are one logical property, never a name coincidence the walk goes looking for), and a builder method borrows the built type’s Jackson-introspected property of that same wire name — also unconditionally, whether or not a validator is supplied — when the borrow is sound. The floor’s own translation renders the
@Positive/@PositiveOrZero/@Negative/@NegativeOrZerofamily for every unscoped member — a creator parameter of either kind (read from Jackson’s merged annotation map, which already carries the same-named field’s annotations), a builder method (read from the method’s own annotations), and a setter through its backing field (avoidsetter method cannot itself carry a Bean Validation constraint, and an annotation on a setter’s or builder method’s parameter is not read) — and for a type-use constraint on a value type, matching what the scoped Jakarta module renders for a field or getter;@Emailand Hibernate-only constraints stay untranslated by the floor. A family keyword never loosens a stricter@Min/@Max/@DecimalMin/@DecimalMaxbound declared on the same member. Builder borrow assumption (owner ruling,spike/deserializer-driven-schema, round 2). A builder method is assumed to set the built property of that same wire name. The borrow now splits on whether that built-type property has a getter:- Getter-backed property: borrowed unconditionally, for any builder. This is exactly what
main’s field walk always did — it published every getter-backed field’s constraints regardless of what any builder method’s body did with the value before assigning it — so the round-2 ruling restores that behavior rather than gate it on the builder’s own shape. A hand-written builder that transforms the value before assigning it (dividing cents into whole units, say) still has its getter-backed built field’s constraint borrowed, exactly as a genuine Lombok builder’s does. - Getter-less property (the private, no-accessor case): bounded to the shape
BuilderBorrowDetectorcan vouch for. Here the borrow is guaranteed by construction only for a Lombok@Buildersetter, which this framework’s own configuration types use throughout; a hand-written builder that does not reproduce that shape carries no such guarantee — the walk has no way to read a method body — so the borrow is bounded to the shapeBuilderBorrowDetectordetects entirely throughjava.lang.reflectover Jackson’s ownRUNTIME-retained annotations: the built type carries@JsonDeserialize(builder = ...)naming astaticnested class of the built type named<Type>Builder; that class carries@JsonPOJOBuilder(withPrefix = "", buildMethodName = "build")— the exact values@Jacksonizedgenerates — and a zero-argumentbuild()returning the built type; and, for the property in question, a builder method with exactly one parameter whose type and name both match the built field exactly. Detection deliberately does not read@lombok.Generated: that annotation carriesRetentionPolicy.CLASS, so recognizing it would mean parsing the compiled class file directly (as JaCoCo’s own coverage exclusion does) rather than through ordinary reflection — disproportionate surface for a detection hint in a module whose compile dependencies are deliberately frozen. A hand-written builder that reproduces the whole shape by hand resolves too — the ruling tolerates that: it says a hand-written builder may go unresolved, not that it must; one that does not reproduce it gets no borrowed constraint at all, and its property is published by type only.
The Bean Validation supplement’s join for a builder method follows this same two-branch rule (round 2 correction), evaluated against the same resolved built-type property the floor’s own borrow shares (see “The join” below): when the built type’s Jackson-introspected property for that wire name has a getter and a backing field,
MetadataConstraintSourcejoins unconditionally, exactly as the floor does — so a constraint declared through an XML mapping, or on a member Jackson does not merge annotations for, still supplements a getter-backed builder property once aValidatoris supplied, the same as it already does for a field or getter with a schema-library member scope. Only for the getter-less property does the supplement still consultBuilderBorrowDetector’s soundness check (MetadataConstraintSourcereflects over the built class directly, so without this gate it would silently re-add a hand-written builder’s constraint the floor never publishes for that shape, whenever aValidatorhappens to be supplied) — an ordinary setter’s join is unaffected either way, since a setter’s own field is never in question the way a builder’s built-type field is. A creator parameter’s own equivalent — the compiled Java parameter name coinciding with an unrelated field’s name — is not this kind of assumption and is never joined on: a creator parameter joins a field only by wire name, which is Jackson’s own statement that the two are one logical property; nothing here reads a constructor body either, so nothing here is ever “guaranteed by construction” for a creator the way a Lombok builder’s setters are. One exception is exact rather than assumed: a record’s creator parameter joins its record component by position, but only for the canonical constructor (where component i is parameter i by language definition); any other creator — including a non-canonical@JsonCreatorconstructor with a different parameter order, and a record’s@JsonCreatorstatic factory — joins by wire name like every other creator (a non-canonical creator parameter whose wire name matches no component name therefore joins nothing: it is neither published nor reserved, the same posture an ordinary class’s renamed creator parameter has). This is what keeps a@JsonCreatorstatic-factory parameter’s own constraint from being dropped even under a validator: Bean Validation itself can join a creator parameter only through a constructor, but the floor reads the parameter’s own annotation directly and does not care which kind of creator it belongs to. - Getter-backed property: borrowed unconditionally, for any builder. This is exactly what
-
The Bean Validation supplement (
Validator.getConstraintsForClass; consulted only when aValidatoris supplied toforInputProfile, and only in addition to the floor above). Unlike the floor, it sees a constraint declared entirely through an XML mapping, a constraint on a member Jackson does not merge annotations for, and aList/array container-element constraint. A constructor-parameter constraint is not one of these: the floor’s own creator-parameter translation already reads it directly from the parameter’s own annotations, so the supplement’s equivalent join — by constructor and parameter index — only ever contributes an addition, where the floor’s own read left something unset. Every keyword it proposes is either an addition — merged onto the floor’s own rendering only where the floor left that keyword unset — or a correction, for the one named set of shapes the floor is known to render incorrectly or not at all (@Range,@Length,@URL, and a@Patternflag — vertiquehq/vertique-dev#606): those replace the floor’s rendering for that keyword unconditionally. The supplement never removes a keyword the floor already rendered correctly, including one declared in a non-DefaultBean Validation group — the floor has no notion of validation groups at all, so a@NotNull(groups = Admin.class)on a plain field still rendersrequired, exactly as generation without a validator would; only what the supplement itself would add is filtered by group. A member the floor and the supplement both render the same constraint for is expected to agree — seeSchemaCorpusMetadataCrossCheckTest’s byte-identical cross-check.
The join. A field- or getter/setter-backed property joins to a PropertyDescriptor by the
member’s Java bean name — the field name, or the name a getter implies by stripping its get/is
prefix, or a setter implies by stripping its set/with prefix — never by the wire name; a
builder method joins the same way, on the built type, following the same two-branch rule as the
floor’s own borrow (see “Builder borrow assumption” above): when the built type’s Jackson-introspected
property for that wire name has a getter and a backing field, the supplement joins unconditionally;
only for the getter-less property does it still require BuilderBorrowDetector to judge the join
sound. For the getter-less shape this matches the floor exactly: an unresolved hand-written builder’s
property contributes no supplement, exactly as it gets no borrow from the floor. For a getter-backed
shape, the two sources both join, but they are not redundant: the floor’s own borrow only ever sees
what the schema library’s own Jakarta Validation module can reach reflectively, so the supplement’s own
addition/correction merge over that same property still adds a constraint declared through an XML
mapping, or on a member Jackson does not merge annotations for, once a Validator is
supplied, exactly as it already does for a field or getter with a schema-library member scope. The
wire-name join is resolved once, by both sources together: InputPropertyDescriber looks up the
built type’s Jackson-introspected property for the member’s wire name a single time and shares that
same definition with the floor’s own borrow and the supplement, so the two can never disagree about
which built property a setter or builder method means. That join still has one known false-positive
shape: a built-type property that merely shares the builder method’s wire name but is actually
assigned by a different member entirely is borrowed from anyway — the module reads Jackson’s own
property identity, never a builder method’s body, so it cannot distinguish “this wire name happens to
match” from “this builder method actually sets this field.”
Setter-only field-borrow fallback (owner ruling,
spike/deserializer-driven-schema). The floor itself joins a setter to its backing field through
Jackson’s own BeanPropertyDefinition#getField() — the field Jackson associates with the same
wire-named property, guaranteed by construction to be the one the setter’s value corresponds to. That
accessor is null whenever Jackson’s property definition carries no field member at all — for
example a private field with a setter and no getter and no public field, or a transient field
regardless of whether a getter is also present; other mapper settings can produce the same absence.
Bounded to exactly “no field member” (the getter’s own presence or absence is
irrelevant — a transient field with both a getter and a setter still has no getField() to join
through, so it falls back the same as the getter-less shape; a non-transient field with a getter
still joins through getField() normally and never reaches this fallback), the floor falls back to
the field whose Java name equals the setter’s own implied name — stripping only its own set/with
prefix (impliedFieldName), narrower than the get/is/set/with convention named above, since
this fallback only ever sees a setter, never a getter — which still carries the constraints the
developer wrote for the value even though Jackson’s own property metadata cannot join it directly. A creator-parameter property joins to a ParameterDescriptor by its declaring
constructor and parameter index (SettableBeanProperty.getCreatorIndex()), never by name; a
static-factory creator’s parameters join to nothing in Bean Validation (constrained constructors
only), which is exactly why the floor’s own annotation read — not the supplement — is what renders
that shape. Where a property matches nothing in the supplement, it contributes no addition and no
correction — a silent no-op, not a failure. A List/array value’s container-element constraints
(getConstrainedContainerElementTypes(), type-argument index 0) merge onto the property’s items
subschema when that subschema is inline, as an addition; a Map value’s element position is also a
described position on the input direction (see “How a Map value is described”),
but this supplement’s own container-element merge still targets a List/array’s items subschema
only, unaffected by that change — it does not merge onto a Map’s own additionalProperties.
The value-position kind (minLength vs. minItems vs. minProperties for the same @Size
shape) is derived from the member’s declared Java type, never from the schema’s own rendered
type keyword — that keyword is unavailable at the point a Map, a bean, or an Optional value’s
constraints are applied. Deriving it from the schema’s type instead was tried and reverted: it
silently misrendered @Size on a Map as maxLength. The declared type is resolved as Jackson
itself sees it for the built type’s own parameterization — never a raw java.lang.reflect.Field or
Method’s reflected type, which erases a generic holder’s type variable to its bound regardless of
what a particular embedding binds it to. A member whose resolved kind is neither STRING, ARRAY,
nor MAP (a number, or an unrecognized kind) contributes no @Size/@NotEmpty/@NotBlank size
keyword at all: none of those three keyword families applies to it, so the correct render is
nothing, matching what the annotation walk renders for the same member kind.
The group filter. Only a constraint whose declared groups are empty or contain
jakarta.validation.groups.Default is proposed by the supplement (as either an addition or a
correction). @Valid cascades are never consulted, because the generator already descends into
nested types on its own.
Rendering. @Size renders minLength/maxLength, minItems/maxItems, or
minProperties/maxProperties depending on the value’s kind; @Min/@Max/@DecimalMin/
@DecimalMax (respecting inclusive), @Positive/@PositiveOrZero/@Negative/@NegativeOrZero;
@NotNull/@NotBlank/@NotEmpty mark the property required — matching Victools’
NOT_NULLABLE_FIELD_IS_REQUIRED exactly, which treats all three identically; @NotBlank/
@NotEmpty additionally floor the size keyword at 1; @Email renders format: email; Hibernate’s
@Length, @Range, and @URL render as corrections (recognized by fully-qualified annotation class
name — never by simple name alone, so an application-defined constraint whose own simple name happens
to collide with one of these, or with a plain Jakarta Validation type, is never mistaken for it — and
never by importing hibernate-validator’s constraint classes, so the metadata source stays usable
with any Jakarta Validation provider). A @Pattern’s flags are embedded as an inline Java regex
modifier group ((?i:...), …; with COMMENTS, a line break precedes the closing parenthesis only
when a trailing # comment would otherwise swallow it) as a correction, after the regexp is first
compiled with its flags — one java.util.regex rejects fails generation with a diagnostic naming the
property, never echoing the expression — measured against the real
io.vertx.json.schema validator, which compiles the pattern keyword with plain java.util.regex.Pattern and honors
this — except CANON_EQ, which has no embeddable modifier character and fails generation with a
bounded diagnostic naming the property. A regexp that compiles with its flags but whose embedded
group does not — an open \Q quote, or comments mode switched on inline, can cause this — fails
generation with a diagnostic naming the property, never echoing the expression. Two or more @Pattern constraints in the default group on one
member — Jakarta Validation’s own @Pattern.List repetition — render as an allOf of one
single-pattern subschema per constraint, sorted for deterministic output, rather than the second
silently overwriting the first; a composed constraint’s own leaves render the same way as if declared
directly, recursively, in addition to — never instead of — the composing annotation’s own rendering
when it is itself a recognized type (Hibernate’s @Range is itself composed of @Min + @Max with
the same bounds, confirmed by disassembly). An unrecognized constraint type is skipped with a
DEBUG-level System.Logger log naming the type and the property (this module carries no
logging-facade dependency; see “Dependencies”). Without a Validator supplied, the annotation walk
does not see a repeated @Pattern at all — the member’s actual reflective annotation is the
@Pattern.List container, not a repeated @Pattern — so this shape is one of the few where a
validator changes the rendered document rather than only supplementing it silently.
Correction timing. A correction is not applied at the moment Victools hands back a scoped
member’s schema: measured, the library’s own Jakarta Validation module does not finish writing every
attribute (pattern under INCLUDE_PATTERN_EXPRESSIONS in particular) by that point, and an
immediate write there made the library’s own later write treat the correction as a conflicting value
and wrap both into allOf instead of the correction ever winning. Corrections and additions for a
scoped member are therefore recorded and applied once the whole document is finished generating,
before nullability and alias expansion run — the same “must wait for the finished document”
technique this module already uses for nullability and alias expansion.
A correction never loosens a stricter bound the floor rendered from a different annotation. A
correction is keyed by annotation type but applied by keyword: @Range(min = 10, max = 20) beside a
separate @Min(15) on the same member would otherwise let the @Range correction overwrite the
floor’s own minimum: 15 with minimum: 10 — looser than both the floor and the binder, since Bean
Validation enforces the conjunction of every constraint on a member, not only the last one rendered.
When both the floor and a correction set the same bound keyword (minimum/maximum/
exclusiveMinimum/exclusiveMaximum/minLength/maxLength/minItems/maxItems/minProperties/
maxProperties), the stricter of the two wins — the larger value for a “min” keyword, the smaller for
a “max” one. For pattern, where “stricter” has no total order, a correction whose value genuinely
differs from the floor’s is combined with it as an allOf of two single-pattern subschemas instead
of replacing it, so a client must satisfy both. The one exception is #606’s own flagged-@Pattern
rendering: when the correction’s pattern is exactly the floor’s own regexp wrapped in an inline
modifier group, it is the same @Pattern annotation rendered twice at different fidelity, not two
annotations in conflict, and the flag-aware rendering still replaces the floor’s outright.
@Schema metadata on a creator parameter or setter follows the same rule. For that unscoped
member shape, pattern, minLength, maxLength, minimum/exclusiveMinimum, and
maximum/exclusiveMaximum from @Schema are applied through this same correction path, so a
differing @Schema(pattern = ...) composes as an allOf with a constraint-source @Pattern rather
than dropping it, and a @Schema bound never loosens a stricter constraint-source bound the floor
already wrote; @Schema’s description, title, format, enum, and nullable keep the
pre-existing unconditional-overwrite behavior. A @Schema bound of the other exclusivity than the
constraint source’s own — exclusiveMinimum beside a floor minimum, or exclusiveMaximum beside a
floor maximum (and symmetrically) — writes a different keyword, so it is never compared against or
dropped for the floor’s bound; both are kept, published together as a conjunction the binder must
satisfy. An any-setter map’s @Schema(minProperties/maxProperties) and a @Size-derived bound on
the same map follow the identical stricter-wins rule.
Bootstrapping a Validator. HibernateValidator.configure().messageInterpolator(new ParameterMessageInterpolator()) avoids an expression-language dependency; the default message
interpolator does not. This module never constructs a Validator itself
(Validation.byDefaultProvider() is never called here) — the caller always supplies one.
Which properties the input direction describes
Section titled “Which properties the input direction describes”forInputProfile(JsonMapperProfile) describes a walked property when Jackson reports it
deserializable or it has a backing field, and its access is not READ_ONLY. A backing field
counts because Jackson populates a private field through reflection wherever the mapper infers
property mutators — Jackson’s default, and the setting every built-in profile leaves alone — so the
commonest DTO shape of all, a private field reachable only through a getter, is described with its
type and format. So are a field-backed getter-only List<String> or Map<String, String> and a
type holding such a shape as a property.
A builder type is filled through its builder rather than through the field. A builder method’s own
constraint is borrowed from the built type’s Jackson-introspected property of the same wire name —
never a raw field-name scan. When that property has a getter, the borrow is unconditional, for any
builder — a Lombok @Builder @Jacksonized type, a hand-written builder that reproduces its shape, or
a hand-written builder that does not (round 2, owner ruling). When the property has no getter, the
borrow stays bounded to the shape the builder borrow detector can vouch for: a Lombok
@Builder @Jacksonized type, or the exact shape it generates by construction. Closed (BG1): a
constrained private field with no @Getter at all is invisible to Jackson’s own introspection, so
the floor has nothing to join the builder method to — that one property still publishes, by type
only, with no borrowed constraint; it is not the whole document that stays unconstrained. When a
Validator supplement is active, it renders the constraint for that property anyway, by its own
field-name join, independent of whether the floor could borrow it.
The backing storage of a @JsonAnySetter or @JsonAnyGetter is never described as a named
property, because the keys those accessors collect are extra keys rather than members of the
object’s property set. The storage is identified by member alone — a field annotated
@JsonAnySetter, the record component whose field that is, a field annotated @JsonAnyGetter, and
the field a method @JsonAnyGetter returns — so a real property is never hidden merely because its
name matches one an accessor method implies: a constrained attribute property beside an any-setter
setAttribute(String, Object) stays described with its constraint. For a type Jackson deserializes
as map-like or collection-like, the any-setter is ignored, as Jackson itself ignores it.
A property marked @JsonIgnore or read-only stays absent from the input document, and a write-only
property is described with writeOnly: true. This rule applies to the input direction only:
forOutputProfile(JsonMapperProfile) describes a property Jackson reports serializable whose access
is not WRITE_ONLY, unchanged.
This rule decides which walked properties are described; it does not make every key the binder accepts a described property. A key the schema does not describe is left to the binder and to Bean Validation.
A type whose resolved deserializer is not a bean deserializer is refused with a bounded
diagnostic when the type’s own class carries an explicit type-level
@JsonDeserialize(using = ...) (or equivalent) — genuinely bean-like structure whose deserializer was
swapped out — or when the mapper’s own reflective introspection still reports a settable property
(a field, a setter, or a creator parameter) for the type even though its deserializer was attached some
other way: a profile module’s SimpleModule.addDeserializer(...), or a BeanDeserializerModifier
wrapper, carries no class-level annotation to detect, so an application DTO shaped this way is still
bean-like and still has a field walk this refusal protects. A type with no settable property at all —
a scalar, container, node, or Vert.x-style opaque wrapper such as JsonObject, JsonArray, or
Buffer, none of which ever had bean properties to begin with — is instead described as accepting any
JSON value, exactly like Object.class/JsonNode.class, both at the root and nested as a member;
there is no field walk such a refusal could be protecting there. The io.vertx.* package is excluded
from the settable-property check outright: a no-argument getter such as Buffer#getBytes() or a
mutable-collection getter such as JsonArray#getList() makes plain reflective introspection report a
property for these well-known wrapper types even though neither is ever bound as a bean.
A DelegatingDeserializer wrapper is unwrapped before this decision, bounded to a pure forwarder
(W2 owner ruling; W-1, round 5 review finding, spike/deserializer-driven-schema). A mapper-wide
BeanDeserializerModifier that wraps every bean deserializer in a forwarding DelegatingDeserializer
subclass is a legitimate, if unusual, module shape — but the generator does not simply trust that the
wrapper forwards; it checks. Unwrapping proceeds through getDelegatee() only while the wrapper’s own
class overrides none of deserialize(JsonParser, DeserializationContext),
deserialize(JsonParser, DeserializationContext, Object), or deserializeWithType(...) itself — i.e.
DelegatingDeserializer remains the declaring class of all three, so every operation is guaranteed to
reach the original bean deserializer unchanged. For that bounded shape the generator unwraps the chain
down to its ultimate delegate, mirroring the existing TypeWrappedDeserializer unwrap, and describes
the wrapped bean from the delegate rather than refusing it as an opaque type-level override; the same
bound applies at all three seams the generator unwraps a DelegatingDeserializer at — the
root/nested-reference seat, the member-level case-insensitive inline path (S-1, round 5 review
finding), and the @JsonUnwrapped unwrapped-child loop (C-1, round 6 finding): an unwrapped child’s
own root deserializer is unwrapped the same bounded way before the loop asks Jackson for its
unwrapping deserializer, so a mapper-wide forwarding wrapper no longer leaves the child’s own bean
deserializer hidden behind it. A DelegatingDeserializer subclass that overrides any of the three
methods — accepting a wire form of its own before, or instead of, ever reaching the delegate — is not
unwrapped: it is treated as a replaced deserializer and refused exactly like the shape described above,
with the same diagnostic, since unwrapping straight to the delegate’s plain bean description would
silently drop whatever the override actually does. This is deliberately narrower than the refusal it
exempts from: a third-party type with a settable property whose deserializer a profile module attaches
directly (SimpleModule.addDeserializer(...), or a BeanDeserializerModifier that returns something
other than a pure-forwarding DelegatingDeserializer wrapping the original bean deserializer) is not a
DelegatingDeserializer chain to unwrap, and stays refused exactly as described above — the remedy for
that shape is still a JsonSchemaTypeOverride. An @JsonUnwrapped child’s own
unwrappingDeserializer(...) result decides its fate, matching Jackson’s own bound (C-1, round 7
finding). When that call returns the very same deserializer instance back, that is Jackson’s own
documented signal that the deserializer does not support unwrapping at all — MapDeserializer, the
abstract-type-id-resolving deserializer, the untyped-Object/JsonNode deserializers,
OptionalDeserializer, and others all take this path — and BeanDeserializerBase.resolve() then
leaves the member bound as an ordinary nested property, which the describer’s own first
(non-unwrapped) property loop already publishes under the member’s own name; the unwrapped-child loop
leaves it there rather than refusing it. Only when the call returns a different instance that does
not resolve to a bean deserializer — a genuine custom deserializer changing the wire shape, not
Jackson declining to unwrap — is the member refused, with the same bounded custom-deserializer
diagnostic the root seam throws, naming the unwrapped member alongside its type so a parent with
several unwrapped members can tell which one was refused. This bound is deliberately narrower than the
root seam’s own refusal shape immediately above: the root seam separately describes a map-like type,
returns nothing for a polymorphic base (left to the Jackson module’s own subtype resolver), and
describes an opaque foreign type as unconstrained — none of which the unwrapped-child loop repeats,
since the parent’s own first property loop already publishes any of those shapes left un-unwrapped as
an ordinary nested property, and repeating them here would duplicate rather than protect that path. A
prior round 6 fix (C-1) refused every such child outright regardless of which of the two instances
unwrappingDeserializer(...) returned, so generation failed with a diagnostic for every DTO with an
unwrapped Map, abstract @JsonTypeInfo base, Object, JsonNode, or Optional member; this is the
corrected bound.
A case-insensitively bound @JsonUnwrapped child is refused, at every seam a wrapper can hide it
behind. requireCaseSensitive runs immediately once an unwrapped child resolves to a bean
deserializer, so the C-1 unwrap fix above also restores this refusal under a mapper-wide forwarding
wrapper: before the fix, the wrapper left the child’s root deserializer un-unwrapped, the loop’s own
instanceof BeanDeserializerBase check failed first, and generation silently succeeded with the
child’s property simply missing instead of refusing the genuinely case-insensitive binding.
A delegating @JsonCreator — object-delegating (Mode.DELEGATING over a single non-array-like
parameter) or array-delegating (the same mode over a List/array-shaped parameter) — is refused with
a bounded diagnostic, in either shape: the whole value is bound through the delegate type, so no named
property of the creator’s own type is ever read from the wire, and a document describing the delegate
type’s shape honestly would open the boundary to keys the binder never accepted through a named
property and leave any constraint on the creator’s own type’s fields dead on input. Declare a
JsonSchemaTypeOverride for the type on the profile, or bind it through a property-based creator.
How a Map value is described
Section titled “How a Map value is described”On the input direction only, a Map<K,V> position — a named property, a creator
parameter, an any-setter’s own extras value, a collection item, or a nested map — describes V’s own
schema as its additionalProperties, through the same shared value-position renderer every other
described value position uses: V’s own type (including a profile override on V, applied first),
V’s own declared constraints, and a type-use constraint declared on V itself — Map<String, @Size(max = 3) String> publishes additionalProperties: {"type":"string","maxLength":3}. A Map
subclass (class Tags extends HashMap<String, @Size(max = 3) String>) is described the same way,
with the type-use overlay source read from the subclass’s own supertype chain
(Class.getAnnotatedSuperclass()/getAnnotatedInterfaces()) rather than a member position, since a
type-level reach carries no member of its own — a further subclass declaring no type argument of its
own still resolves to the nearest ancestor that does. V of Object, JsonNode, TreeNode, or
another entry of the generator’s own unconstrained-value-type list stays open, exactly like that same
type at a named position. A Map-valued Optional<T> entry is described as T’s own schema, marked
nullable, through the same Optional-null-and-constraint rendering a named Optional<T> member
already gets; a null inside a non-Optional map value is rejected as wrong-typed once the
value’s own schema is described — a tightening relative to earlier behavior, where nothing was ever
rejected there because nothing was described there.
Because V’s schema is now built through the same override/refusal chain a named position already
uses, every refusal already reachable at a named position — a custom or module deserializer, a
delegating creator, a case-insensitive binding combined with an alias, nested @JsonUnwrapped on an
any-setter-carrying type — becomes reachable through a map value’s own value type too: router or tool
schema construction now refuses at startup for an affected DTO where it previously published an
undescribed {"type":"object"} and started. Declare a JsonSchemaTypeOverride for the offending
value type to admit it.
An explicit @Schema(additionalProperties = TRUE|FALSE) on a Map-typed member or getter stays
ignored, exactly as at every earlier version — a map’s entries are its own content, so there is
nothing “additional” to forbid, and the renderer writes V’s own schema into additionalProperties
regardless of the annotation’s value. The only behavior this rule adds: when the annotation is present
on a Map-typed member or getter, generation logs one WARNING-level line, through this module’s own
existing System.Logger facility (System.getLogger(...), named after the emitting class — this
module carries no logging-facade dependency of its own; see “Dependencies”), naming the member and
stating the annotation has no effect on the generated input schema. An application routes that log
line into SLF4J the same way it would any other JDK System.Logger output: by adding
org.slf4j:slf4j-jdk-platform-logging to its own runtime classpath. Generation never fails over this
annotation, and a class-level
@Schema(additionalProperties = FALSE) on a bean value type (an any-setter’s own closure rule,
below) is unaffected — this rule is scoped to a Map-typed member or getter specifically.
The output-direction generator, and Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES’s own per-builder
default, are unaffected by any of the above: this renderer is reachable only from
forInputProfile’s own provider chain.
How an any-setter’s extra keys are described
Section titled “How an any-setter’s extra keys are described”A type with a @JsonAnySetter describes its extra keys through additionalProperties, typed by the
any-setter’s value type: the map value type of a field-level any-setter, or the second parameter of
a method-level one. A field’s map value type is the content type Jackson resolves for the field, so a
map subclass declares it correctly however its own type parameters are written — a
StringKeys<Integer> over LinkedHashMap<String, V>, a Reversed<Integer, String> over
LinkedHashMap<K, V>, and a non-generic IntMap extends LinkedHashMap<String, Integer> all describe
integer extras — and a generic value type such as List<Integer> keeps its arguments. The value type is published as the generator’s own definition of that type, so a
profile override, a format, and a shared definition apply to an extra value exactly as they do to a
named property — a Map<String, LocalDate> any-setter’s extras carry format: date, and under
vertique-strict a Map<String, BigDecimal> any-setter’s extras carry that profile’s decimal
fragment.
An any-setter extras value (and a map value — see “How a Map value is described”)
declared as Optional<T> is described as T’s own schema, constraints included, and admits an
explicit null, matching Jackson’s own Optional.empty() binding.
When an @JsonUnwrapped parent and one or more unwrapped children each declare an any-setter, Jackson
feeds a shared extra key into every one of them, and the key’s additionalProperties describes the
conjunction of every any-setter’s own value schema — composed as an allOf of each any-setter’s own
value description (a type-use constraint on a value included) and folded by the generator into one
schema where the parts are plain, so a value violating any one any-setter’s own constraint is rejected.
A bean-valued part is described inline rather than by reference, unless its value type carries a
profile override, which is kept as a reference; a self-referential bean-valued part fails generation
with a bounded diagnostic naming the type and the JsonSchemaTypeOverride remedy rather than being
described from the library’s own reflection. Where several such any-setters bound their own map with
@Size or @Schema(minProperties/maxProperties), the stricter bound wins across the conjunction. A
key that is a named property of a child is described by that named property alone.
A member-level @Schema(additionalProperties = FALSE) on a property whose value type declares its
own any-setter, directly or through an unwrapped member of its own, closes that one member’s own
extras by describing the member inline — reusing the case-insensitive inline path’s own
override-first, requireNotDelegating, and scalar-creator machinery — while the same value type
referenced elsewhere, without the annotation, keeps its own shared, open definition. A member that
is both case-insensitive-inline and FALSE composes as one inline description, case-insensitive
handling kept. A member whose value type carries a profile override cannot honor FALSE from the
member: generation refuses with a bounded diagnostic naming the member and the remedy (declare the
closure inside the override fragment instead). A self-referential member reached through the inline
description, or through a plain reference to a type still being described inline, refuses the same
way rather than overflowing the stack or falling back to a standard, reflection-built definition. A
member-level TRUE, or the annotation’s absence, never loosens a class-level FALSE already
closing the value type.
An unconstrained value type — Object, JsonNode, TreeNode, or a wildcard or raw form resolving
to one — is described as the empty schema {}, which accepts every JSON value. A class-level
@Schema(additionalProperties = FALSE), declared or inherited, keeps the object closed and is never
overridden. A class-level @Schema(additionalProperties = TRUE) says only that extras are allowed,
which the typed description already says more precisely, so the description wins. A type Jackson
deserializes as map-like or collection-like is described exactly as if it declared no any-setter:
Jackson never routes a key to that any-setter, so describing it would reject legal map entries.
Reserved names beside described extras
Section titled “Reserved names beside described extras”Where extra keys are described on a case-sensitively bound type, the document also carries
"propertyNames": {"not": {"enum": ["id", "role"]}}which lists one reserved set, computed as a difference rather than as a list of categories: every
name Jackson binds on input for the type, minus every name the document publishes under
properties, minus every name whose Jackson property definition carries no member at all. Without
it, a name the document never published would be accepted as an ordinary extra key and bound
straight into the member it names. Its members are therefore a consequence of the rule rather than
separate cases:
- a name marked
@JsonIgnore, and a name a class-level@JsonIgnorePropertiesignores; - a name whose access is read-only, or that is otherwise invisible on input;
- the storage field a method
@JsonAnyGetterreturns, which Jackson fills through that getter; - a name bound only through a setter with no field, through an accessor pair over a differently
named field, through a
@Schema(hidden = true)field, or through atransientfield; - a
@JsonAliasspelling more than one property claims, and a spelling of a property the document does not publish under its own wire name — neither is published, so neither is subtracted; - a
@JsonUnwrappedchild’s own alias spelling, and its own hidden, ignored, or bound-but-unpublished name, keyed by the child’s wire name with any unwrapped prefix or suffix applied — folded in beside the parent’s own names, scoped to when the parent actually has an any-setter (the only case where an unrecognized key binds anywhere at all). A nested@JsonUnwrappedchain under an any-setter type — where this one-level fold cannot itself be sound — is refused with a bounded diagnostic instead.
A field carrying both @JsonAnyGetter and @JsonAnySetter reserves no storage name, because
Jackson stores a key named after it as an ordinary entry of the map. The published-name subtraction
is by member and never by spelling, so a property the document publishes under some other name is
not reserved. The memberless subtraction fails open for the one shape whose member identity cannot
be recovered — a @JsonCreator parameter renamed away from the field it populates — which therefore
keeps accepting the traffic it already accepted; constrain that shape with Bean Validation. A
declared propertyNames in the document comes only from a profile override fragment, which replaces
the whole description of its class: it is published exactly as declared, and is never guarded or
listed — the generator itself never writes a propertyNames alongside one already on the node.
The redaction manifest. The propertyNames rule above, and the case-insensitive reserved-name
entry described below, are guards AnnotationJsonSchemaGenerator marks as it emits them.
describe(Type) returns, beside the canonical document, a RedactionManifest whose sorted RFC 6901
pointers name every such guard in the finished document — including every copy alias expansion or
folded-copy resolution makes of one — so removing exactly those locations leaves no reserved name in
any spelling or fold, while every other assertion, including the non-ASCII refusal below, remains. A
propertyNames a profile override fragment declares carries no guard and is never listed. The mark
is a generator-private keyword, x-vertique-reserved-name-guard, removed before the document is
canonicalized, so no generated document carries it. A profile override fragment that carries it as a
member of a schema object, at any depth, is refused when the generator is constructed — as one
carrying the alias-plan keyword is (see “How an alias spelling is described”) — because the
manifest would otherwise list an assertion the generator never emitted; the refusal names the
keyword and the profile. See “Canonical output” below.
Case-insensitive binding and Unicode code folding. A type bound case-insensitively (mapper-wide,
class-level, or member-level @JsonFormat) is described with patternProperties — one ASCII
case-folding pattern per bound name that has an ASCII letter, since Jackson’s
own case-insensitive lookup measurably uses String#toLowerCase()/toUpperCase() with no explicit
Locale, which a fold pinned to any one locale could silently drift from. The key matches every
other ASCII casing of the name and never the exact spelling, which properties validates, so a
value is checked once whatever the casing of its key: a negative lookahead excludes the exact name,
then the fold follows (name gives ^(?!name(?![\s\S]))[nN][aA][mM]eE; a.b gives
^(?!a\.b(?![\s\S]))[aA]\.bB). The entry’s value is a copy of that member’s own
resolved schema, whatever its type, so a differently cased key carries the same constraints as the
exact spelling — including a member described through a definition reference (a nested DTO, a
map, an enum, a polymorphic base, or a list, array, or Optional of those). A name with no ASCII
letter has no other spelling and gets no entry; a name Jackson folds beyond ASCII is still refused
when the schema is generated. The copy is made by the generator and its private placeholder keyword,
x-vertique-folded-copy, never appears in a generated document; a profile override fragment that
carries it as a member of a schema object is refused when the generator is constructed, like the
guard keyword above. A body spelling such a member in a non-exact casing with a value its schema
rejects is now rejected, as the exact spelling already is (before, that copy was an unresolved,
near-empty schema and the value was accepted); verdicts for the exact spelling are unchanged. The
published input schema text of every case-insensitively bound type differs from earlier releases.
The fold is anchored with
(?![\s\S]), not $: io.vertx.json.schema compiles the pattern keyword with plain
java.util.regex.Pattern, whose $ — without Pattern.MULTILINE — still matches immediately
before a single trailing line terminator, not only at the true end of input; a key ending in a
newline would otherwise wrongly match the fold. (?![\s\S]) is the ECMA-262 end-of-input form — a
negative lookahead asserting that no character follows — and under java.util.regex it matches
exactly what \z matches, with no exception for a trailing line terminator. Every
case-insensitively bound type additionally carries a propertyNames rule refusing any key
containing a non-ASCII code unit, unconditionally — where extras are also described, paired with
a second, separate refusal of the reserved names’ ASCII case folds as the two entries of one allOf
(the non-ASCII refusal first); where they are not, on its own. Refusing a key matching either pattern
is exactly refusing a key matching their alternation, so the separated shape accepts the same keys a
single combined pattern would; only the reserved-name entry (propertyNames/allOf/1) is a guard the
redaction manifest lists, so removing it leaves the non-ASCII refusal in place. This closes a real gap: a
non-ASCII code point can fold to an ASCII letter under Java’s locale-independent Unicode case
mapping regardless of locale (U+212A KELVIN SIGN folds to ASCII k), so a key spelled with it binds
at the binder to the same member an ASCII spelling would. Where extras are described, the
ASCII-only patternProperties fold and the reserved-name pattern both miss it at the schema, so
without this rule the key would fall through to additionalProperties and validate as a permissive
extra instead of against the real member’s own constraint. A closed type (no any-setter) at the
REST gate has no other closure at all — REST has no hardener, and a closed type publishes no
additionalProperties — so without this rule the key would simply be accepted and bound; the rule
is emitted unconditionally for exactly this reason, not only where extras are described. A closed
type still relies on Bean Validation, when one is supplied, as its own backstop after binding.
Member-level case-insensitive binding. A member bound case-insensitively only through its own
contextual @JsonFormat(with = ACCEPT_CASE_INSENSITIVE_PROPERTIES) is described inline at that
position rather than by reference to the type’s ordinary (case-sensitive) shared definition, since the
same class used elsewhere without the annotation stays case-sensitive there. Before describing it
inline, the same delegating/array-delegating refusal the type would face at any other position still
runs, and a profile override declared for the member’s type still applies — resolved through the
ordinary reference lookup, which re-enters the full provider chain — in preference to the inline
description. A class-level @Schema(additionalProperties = FALSE) on the value type, declared or
inherited, is preserved on that inline description exactly as on a shared definition — the hand-built
node does not drop the closure.
How an alias spelling is described
Section titled “How an alias spelling is described”Every @JsonAlias spelling of a visible input property that does not back an any-accessor is listed
under properties with a copy of the schema that property is published with, so the same
constraints apply under either spelling. Without it an alias reaches a gate undescribed: {"qty": 999} binds a property declared @Max(10), and an unknown enum value under its alias binds the
default constant.
"properties": { "quantity": {"maximum": 10, "type": "integer"}, "qty": {"maximum": 10, "type": "integer"}}A spelling is listed exactly where its property’s own wire name is published, because listing copies
that entry: a spelling of a property the document never publishes — a @Schema(hidden = true)
field, an accessor pair with no same-named field, a property published under some other name — is
listed nowhere and stays a reserved name. A spelling that is already another property’s name is not
listed either — whether or not the document publishes that property, because the type still binds
that name — and neither is a spelling more than one property of the type claims: the generator
cannot predict which claimant Jackson binds such a key to, so publishing it would attach one
claimant’s schema to another claimant’s value. A contested spelling is therefore published nowhere,
named in no rule, and reserved where extras are described; constrain that shape with Bean
Validation.
A required aliased property leaves the top-level required list, because one rule per aliased
property states which spellings may appear instead. Which rule depends on the selected profile’s own
mapper, and on nothing else: a profile whose mapper enables
JsonParser.Feature.STRICT_DUPLICATE_DETECTION — of the built-ins, vertique-strict alone — gets
the strict form.
| Profile | Required property | Optional property |
|---|---|---|
Lenient (system, vertique) |
at least one spelling: anyOf of {"required": [spelling]} |
no rule |
Strict (vertique-strict) |
exactly one spelling: oneOf of the same branches |
at most one: the same branches plus {"not": {"anyOf": [...]}} |
"oneOf": [ {"required": ["quantity"]}, {"required": ["qty"]}, {"not": {"anyOf": [{"required": ["quantity"]}, {"required": ["qty"]}]}}]The rules are appended to one allOf, or stand alone when there is one rule and its keyword is
free, and their branches carry only required or not, never properties, so a consumer that
closes an object carrying properties does not close a branch. The rule is the schema’s alone: a
Jackson binder accepts several spellings of one property under every profile, so the strict form is
stricter than the binder rather than a description of it.
Listing runs over the finished document, after generation, so every reference a copied schema
carries is already resolved. The plan is carried in the document under one generator-private
keyword, x-vertique-alias-plan, which that pass removes; a type publishing a property under that
exact wire name — including one listing would publish under it, so an alias spelling equal to the
keyword is refused exactly as a property named for it is — is therefore refused at generation with a
bounded diagnostic naming the type and the name, rather than being published stripped of its
constraints. Rename such a property, or such a spelling, on the wire — for example with
@JsonProperty or @JsonAlias.
A profile override fragment that carries the keyword as a member of a schema object, at any depth,
is refused when the generator is constructed for a direction the fragment applies to: expansion would
otherwise strip it without a trace, or execute it as a plan against the enclosing schema. Literal
data is exempt. The listing pass and the refusal never enter the value of const, enum,
default, examples, or example, so a fragment {"const": {"x-vertique-alias-plan": "mandatory", "value": "ok"}} is published exactly as written and still accepts only that object. A property whose
own name is one of those keywords is a schema like any other and is still expanded.
How an unconsolidated allOf is folded (input direction)
Section titled “How an unconsolidated allOf is folded (input direction)”Victools sometimes leaves an object allOf unconsolidated — most visibly for a polymorphic
subtype whose discriminator is also a declared property (the base part describes it with type: "string", the discriminator part with const: "<name>"), and for a @JsonUnwrapped child whose
definition provider leaves a single-part allOf beside the holder’s own already-flattened
properties. After every other post-generation pass, and only for a generator built through
forInputProfile(...), this module folds every plain part with an eligible target into one flat
properties set, equivalent under the refusal conditions listed below, per Draft 2020-12
conjunction semantics: a property both parts declare becomes {"allOf": [...]} of the two branches
(or is left as one copy when they are already equal), a property only one part declares is added
directly when the receiving schema constrains no key it does not name (or is conjoined with its
additionalProperties schema, when it has one), required becomes the union of both parts’, the
receiving schema’s title/description wins when it has one (otherwise the folded part’s is
copied), and a folded part’s own type: "object" is copied onto the receiving schema when it
declares no type of its own. The fold is refused — leaving the allOf exactly as generated —
whenever the receiving schema’s additionalProperties: false, patternProperties,
unevaluatedProperties, or propertyNames could make adding an unnamed key unsound; whenever the
receiving schema already declares a type other than the literal "object" and the folded part
requires object-only; or whenever a local $ref elsewhere in the document points into the region
the fold would rewrite. The result is that a subtype whose own schema is inline — not a $defs
reference — and whose discriminator is also a declared property now publishes one flat property set
with the discriminator present once, rather than split across sibling allOf parts — the shape an
MCP-style hardener that closes a non-root object by provenance (additionalProperties: false on any
object carrying non-empty properties, no $ref, no declared additionalProperties of its own)
would otherwise close one part at a time, turning a property published only on a sibling part into a
rejected “additional property”. A subtype whose own schema is a $defs reference — because the
subtype is also used directly as a field — keeps its discriminator part unfolded beside that $ref
sibling instead: the $ref part is never itself a fold target, so the discriminator part has
nothing eligible to fold into and is left for such a hardener to close on its own. The output
direction is unaffected: this is a normalization of the input direction’s own published document,
wherever the fold applies. See AllOfFold’s class Javadoc for the full argument.
Canonical output
Section titled “Canonical output”generateCanonical(Type) returns a fresh, compact JSON document with every object member whose
key is default and whose value is exactly the string ##default removed, and every object’s
keys recursively ordered by String.compareTo UTF-16 code-unit order; arrays are never reordered.
The document is emitted through a generator-owned neutral writer, never through the profile
mapper, so the valid-document guarantee does not depend on the mapper’s own serialization
configuration. Equal resolved types, annotations, construction mode, mapper configuration,
selected profile direction, and canonical override fragments produce byte-identical documents
across independent instances and repeated calls. Calls on one instance are safe from multiple
threads; the complete generation and canonicalization operation is serialized per instance.
describe(Type) runs the identical generation and returns a CanonicalSchema pairing that exact
text, as json(), with a RedactionManifest: the sorted RFC 6901 pointers of every reserved-name
guard in the finished document (see “Reserved names beside described extras” above), and a digest —
"sha-256:" plus the lowercase hexadecimal SHA-256 of the UTF-8 bytes of the canonical form of the
parsed document. matches(text) recomputes that digest for text with a strict reader — one that
refuses content after the first JSON value, while still accepting trailing whitespace, and refuses an
object repeating a key — and compares it with the manifest’s own, so it holds for json() however
that text is later re-rendered (reordered keys, inserted whitespace), and fails for any difference
that survives parsing and canonicalization. A difference parsing itself erases — an escaped spelling
of a character, or another notation of a number that parses to the same value — does not change the
result. It returns false for unparsable or malformed input and never throws for a content reason. generateCanonical(Type) is exactly
describe(type).json(); an output-direction generator’s manifest, and a withVictoolsDefaults()
generator’s, is always empty, since neither emits a guard.
Any failure — an unrepresentable Type, an invalid or conflicting profile override declaration,
a detected structural conflict, or an unexpected Victools failure — is normalized to
JsonSchemaGenerationException, a single bounded exception type with no Victools type in its
signature. Stack exhaustion during generation, which is how a pathologically deep type graph fails
inside the generator’s recursive descent, is normalized the same way, so a deep type does not
bypass the bounded failure contract merely because the JVM reports it as an Error. A VM-level
error such as OutOfMemoryError is deliberately not normalized: it describes the runtime rather
than the requested type, and propagates unchanged.
A failed call leaves the generator fully reusable: it restores the per-generation state the underlying generator holds before propagating, so a rejected type never changes what a later call on the same instance publishes. Reuse a generator freely after a failure — there is no need to discard and rebuild one.
Profile overrides consumption
Section titled “Profile overrides consumption”A profile’s jsonSchemaTypeOverrides() (declared in dev.vertique.core.json.JsonMapperProfile,
dev.vertique:vertique-core) lets a profile describe an exact-class wire shape that Jackson
inspection cannot infer — for example a BigDecimal field a matched custom serializer/deserializer
pair represents as a bounded decimal string rather than a JSON number. The generator applies a
profile’s override fragment as the baseline wire contract for that exact class wherever it is
reached — the mapped root type, a property, or a collection element — and never to a map key.
Property-level Swagger schema metadata and applicable Jakarta constraints then narrow that
baseline through explicit conjunction; neither contributor overwrites the other’s declared
keyword.
An INPUT override for a bean-like class must declare properties or additionalProperties.
This is the documented remedy for a generator refusal (the custom-deserializer, delegating-creator,
and case-insensitive refusals above), so nothing else validates that a declared fragment actually
describes or closes anything. A bare {"type":"object"} — the natural one-line remedy — publishes
as a non-root object with no properties and no additionalProperties, which the MCP hardener
deliberately leaves open (it only closes an object that already declares a non-empty properties),
and as an unconstrained body member at REST. Construction fails with a bounded diagnostic instead.
“Bean-like” is decided the same way the custom-deserializer refusal decides it (a settable property
by the mapper’s own reflective introspection, with the same java./javax./jakarta./
com.fasterxml.jackson./io.vertx. package exclusions), and a fragment that fully constrains its
own wire shape some other way is exempt — it carries no open-object risk this rule exists to catch
even though it declares neither properties nor additionalProperties. Exempt shapes: a type
that wholesale replaces the object shape — a single non-object string, or an array not containing
"object"; an enum; a const; and a oneOf/anyOf/allOf whose every branch itself qualifies
by this same rule, recursively. A bare {"type":"object"} satisfies none of these and still fails
construction. OUTPUT-direction overrides are unaffected.
A constraint that does not apply to the substituted wire type is not published as if it did. The
numeric-domain keywords minimum, maximum, exclusiveMinimum, exclusiveMaximum, and
multipleOf are suppressed at a property whose effective declared type excludes both number and
integer — so @DecimalMin("0.01") or @Schema(multipleOf = 0.01) on a BigDecimal the profile
republishes as a decimal string emits no numeric keyword against that string schema. Which
contributor supplied the keyword is irrelevant; the effective wire type alone decides. Bean
Validation still enforces the constraint against the materialized Java value — only the published,
wire-facing keyword is dropped.
Suppression is per-property and never rewrites a shared $ref target, because several properties
may reference the same generated definition and one property’s effective type is not the others’
to narrow. A shared definition is cleaned only on its own terms — when the definition itself
declares a type that excludes both numeric types. So when an override fragment contributes a
numeric keyword without declaring a type, that keyword stays visible at a property whose
effective type is not numeric. It is inert there rather than wrong: JSON Schema applies a numeric
keyword only to a number instance. Declare a type in an override fragment that carries numeric
bounds if you want those bounds confined to numeric referrers.
Accepted type grammar
Section titled “Accepted type grammar”generateCanonical(Type) accepts a resolved type, recursively: a non-null Class (including a
primitive class, an array class, and a raw generic class), a ParameterizedType whose optional
owner type, raw type, and arguments are themselves accepted, and a GenericArrayType whose
component type is accepted. It rejects null, a TypeVariable, a WildcardType, any nested
occurrence of either unresolved form, and an unknown custom Type implementation. The rejection is
eager — it happens before Victools is invoked — so an unrepresentable type never produces a
partially built document. A recursive object graph is fully supported; it is not an unresolved
type.
Constraints and common mistakes
Section titled “Constraints and common mistakes”Three annotation combinations fail generation rather than producing a schema that quietly misdescribes the wire:
- An
implementation = ...redirect on a property whose declared type graph carries a profile override. The Swagger module redirects the property’s resolved type before the profile’s override is consulted, so the override fragment would be dropped without a trace. Generation therefore fails with a boundedJsonSchemaGenerationExceptionnaming the property. Declare the wire shape through the profile override or throughimplementation, not both. Every form the Swagger module reads the redirect from is covered: a direct@Schema(implementation = ...),@ArraySchema(schema = @Schema(implementation = ...))on a container’s element, and@ArraySchema(arraySchema = @Schema(implementation = ...)). An annotation on either the field or its accessor counts, matching how the Swagger module resolves it. The detection reads the property’s declared type — resolved against its declaring context, so a member inherited from a generic supertype is checked against the binding subtype’s actual class — plus, recursively, its type arguments and array element types, including the element or payload type a subclass or implementor binds in itsextends/implementsclause rather than declaring itself; a map key position is excluded, since a map key is never fragment-bearing. This search is deliberately wider than the set of positions an override fragment is actually published at: it rejects the combination whenever an override is reachable from the declared type, not only where the fragment would have applied, because a rejection is visible and resolvable while a dropped fragment is neither. A declared type graph nesting deeper than 64 levels also fails, because past that bound the absence of an override has not been proven. The search follows the declared type’s own parameterization — its type arguments, array element, and inherited container or wrapper bindings — and does not descend into the members of the types it finds. A redirect on a property whose declared type is a DTO therefore succeeds even when that DTO’s own fields carry profile-overridden types: the redirect replaces the DTO’s schema wholesale, exactly as asked, and no fragment the profile publishes elsewhere is contradicted. Useimplementationonly where you intend the declared type’s schema — including anything nested inside it — to be replaced. - A conjunction of disjoint explicit
typekeywords. After generation, every conjunctive location — a subschema node, its directallOfbranches, and its locally resolved$reftargets — must admit at least one explicittypeacross the declarations found there. The types are intersected, refined by the one subtype relation JSON Schema’s type vocabulary carries:integeris the integral subset ofnumber, so conjoining the two narrows tointegerrather than emptying —@Schema(allOf = {Integer.class})on adoubleproperty generates normally. An empty result is an unsatisfiable contract and fails generation.anyOfandoneOfbranches are alternatives, not conjunctions, so a nullable overridden property remains valid. Only a$refthis module can resolve inside the document itself —"#"or a"#/"-rooted JSON pointer — is followed; a$anchorreference such as@Schema(ref = "#anchorName"), an external URI, or an unresolvable pointer is skipped, so it never fails generation. A resolvable pointer is followed only when its target is itself a schema position (see below):#/$defs/Moneyand#/properties/amountare conjoined, while a pointer at data such as#/default, or at the container object under#/$defs/Money/properties, contributes nothing. - A
@Schema(name = ...)rename that publishes two members under one name. In the profile-aware modes, a walked field that Jackson does not attach to any property of its own, and that is renamed onto a property backed by another field the schema library walks, would publish both fields under one name, the renamed one with the other’s input or output visibility and wire name. Generation instead fails with a boundedJsonSchemaGenerationExceptionnaming the type, the renamed member, and the wire name of the property it collides with. Rename one of the two properties, or name them apart on the wire with@JsonProperty. A rename onto a property no other walked field backs is unaffected and keeps its schema: the Lombok-style@Schema(name = "active") boolean isActivebehindisActive()andsetActive(...), or anmNamefield behindgetName()andsetName(...), publishesactiveornameas before. So is a rename to the member’s own property name, or to a name no property carries. Where a field name and its accessor property differ,@JsonProperty("active")on the field joins the two for Jackson as well. A rename that does not fail generation can still publish a member under a name other than the one Jackson serializes —@Schema(name = ...)onto a name no property carries is the ordinary case.AnnotationJsonSchemaGenerator#outputRenames(Type)reports every such member reachable fromtype’s output document — its root and every nested object, collection element, or shared definition it describes, each member once however often it is reached — as anOutputRename(declaringType, member, serializedName, schemaName), ordered bydeclaringTypethenmember(String.compareTo); it returns an empty list when no reachable member’s published name diverges. The document itself is unchanged:outputRenamesonly detects the divergence, under the same accepted type grammar, boundedJsonSchemaGenerationExceptionfailure contract, and per-instance lock asgenerateCanonical. When a member carries both@JsonPropertyand@Schema(name = ...), only a non-empty@JsonPropertyvalue naming a name other than the member’s own takes precedence:@JsonProperty("wire") @Schema(name = "label") String codepublisheswire, the name Jackson serializes, and is not reported. A bare@JsonProperty, or@JsonProperty("code")naming the member itself, does not take precedence:@Schema(name = "label")still publisheslabelwhile Jackson serializescode, and the member is reported as(…, code, code, label).outputRenamesanswers only for a generator built byforOutputProfile(JsonMapperProfile); calling it on an input-direction generator (forInputProfile) or one built bywithVictoolsDefaults()throwsIllegalStateException.dev.vertique:vertique-rest-openapi-docsrefuses to publish an output type for which it reports a member.
@Schema(type = ...) has no effect in this module; implementation is the supported way for a
property to contribute a type shape.
@Hidden alone hides nothing. A member or type marked @io.swagger.v3.oas.annotations.Hidden
without @Schema(hidden = true) stays in the document either generator produces. An input member
stays described, so a gate that validates against the document keeps enforcing its constraints; an
output member stays published. To keep a member out, use @Schema(hidden = true) on the field or
getter the generator describes it through (the two are read together), except in the cases below;
@Hidden is no substitute.
@Schema(hidden = true) does not hide every member or type:
- the input direction still describes a property the profile’s mapper binds through its setter, whether the marker sits on the property’s field or on its getter; the output direction hides it;
- neither direction hides the flattened content of a
@JsonUnwrappedmember marked@Schema(hidden = true); - the input direction still describes a member bound through the inline nested-bean branch (a nested
bean described inline because the member’s own
@JsonFormatmakes it case-insensitive or a member-level@Schema(additionalProperties = FALSE)closes its extras) and a member bound through a converter (@JsonDeserialize(converter = ...)), whichever declaration carries the marker; - the input direction still describes a property bound through a builder method, whether the marker sits on the builder method or on the built type’s field; the output direction hides it;
- the input direction still describes a property bound through a static factory creator’s parameter, even when its backing field carries the marker; the output direction hides it;
- the input direction still describes a map member rendered through its value constraints (a value position that carries a constraint), whichever declaration carries the marker;
- neither direction hides an enum constant marked
@Schema(hidden = true); - neither direction honors a
@Schema(hidden = true)a mix-in declares for a member; - neither direction honors a
@Schema(hidden = true)declared on a creator parameter itself: the generators read the property’s field or getter, so the property stays described; - neither direction honors a
@Schema(hidden = true)that reaches a field only through a Jackson annotation bundle (an annotation meta-annotated@JacksonAnnotationsInside): the property stays described; - neither direction honors a
@Schema(hidden = true)on a setter: the property stays described; - neither direction honors a class-level
@Schema(hidden = true): the type is still described wherever the document references it.
Such a member or type stays in the document, and the report below lists it.
AnnotationJsonSchemaGenerator#hiddenMembers(Type) finds every member and type that type’s
document describes and that carries @Hidden, @Schema(hidden = true), or both, in either
direction. Each entry is a HiddenMember(declaringType, member, marker, hideableBySchemaHidden),
where marker is HidingMarker.HIDDEN, SCHEMA_HIDDEN, or BOTH. A SCHEMA_HIDDEN entry is a
member whose marker the generator ignores at that position (the cases above, or a marker on a
declaration other than the one it reads); a member whose @Schema(hidden = true) the generator
honors is left out of the document and is not reported. The document’s reach is its root and every
type it describes — a nested object, a collection or array element, an Optional payload, a shared
definition, and, in the input direction, a map value — together with every property described on
them.
hideableBySchemaHidden answers one question: would declaring @Schema(hidden = true) on the
property’s own field, or on its getter when the mapper sees no field, make this generator leave the
property out at the position where the document describes it. It is true only when the position is
described through a field or getter the generator checks for the marker, which includes every
output-direction property. It is false for:
- a type, an enum constant, a
@JsonUnwrappedmember, and an any-setter; - in the input direction, a property described through a setter, a builder method, a static factory creator’s parameter, or a constructor creator’s parameter without a backing field;
- in the input direction, a map member rendered through its value constraints, a member bound through the inline nested-bean branch, and a member bound through a converter;
- a position with no schema-library member scope, where the generator checks neither declaration.
A member reached from several positions is reported once, with the conjunction of the flags at each
position. The positions listed above are the Stable contract for false; additional positions may
begin returning true when the generator starts honoring @Schema(hidden = true) there — additive
expansion only, never a narrowing of an existing true.
Each reported member is a Java member, reported under its own name and the class that declares it:
- a described property’s field, getter, or setter carrying a marker, whether the mapper binds the
property through one of them, through a record component, or through a creator parameter, so a
marker on the getter of a private field
secretreportsgetSecret, and a marker on a record component reports the component’s name once; - a creator parameter carrying a marker, named by the creator and the zero-based parameter index:
<init>#0for a constructor’s first parameter,of#1for the second parameter of a static factory methodof, under the class that declares the creator; - in the input direction, a builder method carrying a marker, under the builder class, and an
any-setter carrying one when the document describes the type’s extra keys; the output direction
describes neither. An any-setter declared on a creator parameter carrying
@Schema(hidden = true)is reported like any creator parameter, as<init>#i(ormethod#ifor a static factory), withhideableBySchemaHiddenfalse; - a method carrying a marker that a described getter or setter overrides or implements, a generic declaration overridden with concrete parameter types included, named where it is declared;
- a
@JsonUnwrappedmember carrying a marker, in either direction, when the document describes its flattened content, a getter-only or read-only member included; - a constant carrying a marker of an enum the document describes, which the document still lists, reported under the enum’s binary name and the constant’s name.
A type carrying a marker is reported with a null member wherever the document describes it: as
the root, as a member’s type, as a collection or array element, inside an Optional, as a map value
in the input direction, and as a polymorphic base that the document describes only through its
subtypes, reached through a member at any of those positions. A described subtype of a class
carrying a class-level @Schema(hidden = true) is reported for the subtype as a SCHEMA_HIDDEN type
entry, because @Schema is @Inherited; @Hidden is not, so an inherited @Hidden never counts.
The type’s own view decides first (a mix-in’s @Schema, then the declared one, then one reached
through a bundle), and the inherited @Schema is read only when that view holds none. A class that
declares @Hidden directly and inherits a hidden @Schema is reported as BOTH. The base type,
when the document describes it as well, keeps its own entry.
Only a @Schema declared on a superclass is inherited, and the nearest one wins, as Java’s
@Inherited defines it, so an intermediate class declaring @Schema(hidden = false) stops a hidden
@Schema above it from reaching its subtypes. A @Schema on an interface is never inherited, which
matches Class#getAnnotation; swagger-core reads only the declared annotation. A mix-in registered
for a superclass is not inherited by a subtype either: the subtype’s own mix-in is read through its
own view. To publish a subtype of a hidden class, declare its own @Schema without hidden.
A marker counts as the profile’s mapper sees it in the generator’s direction: declared on the member
or type directly, through a Jackson annotation bundle (an annotation meta-annotated
@JacksonAnnotationsInside) at any depth, or through a mix-in the mapper registers, that mix-in’s
superclasses included, and, for a method, on a declaration the method overrides or implements. A
mix-in’s marker reports the target class’s member or type, never the mix-in. A mix-in registered for
a superclass reaches the members a subclass inherits and a method of the same signature that only a
subclass declares. Each declaration is judged by its own markers: the same method declared on
several classes of a hierarchy is reported under each declaration that carries one, with that
declaration’s own marker.
@Hidden and @Schema(hidden = true) are read on fields, methods, creator parameters, enum constants,
and types. @Hidden targets methods, fields, and types only, so a creator parameter carries it only
through an application’s own bundle annotation with a parameter target; @Schema(hidden = true) can
sit on a creator parameter directly and is read there. A profile whose mapper disables annotation
processing (MapperFeature.USE_ANNOTATIONS) sees no marker at all, so nothing is reported, while the
generator still honors @Schema(hidden = true).
A member the document does not describe is not reported: one that @Schema(hidden = true) hides,
and one the profile’s mapper does not bind (input) or serialize (output), such as a @JsonIgnore
member. Each entry is reported once however often the document reaches it.
The list is ordered by declaringType, then member (String.compareTo), with a type’s own entry
first within its declaring type; it is unmodifiable, and empty when nothing carries a marker. The
call runs one generation under the same accepted type grammar, bounded
JsonSchemaGenerationException failure contract, per-instance lock, and restore-on-failure
behavior as generateCanonical, and it changes nothing a later call on the instance publishes. A
generator built by either forInputProfile overload or by forOutputProfile answers for its own
direction; one built by withVictoolsDefaults() throws IllegalStateException.
What counts as a schema position
Section titled “What counts as a schema position”Both post-generation passes — the disjoint-type check above and numeric-keyword suppression —
traverse the generated document through Draft 2020-12 subschema positions only, starting at the
document root. They descend into not, if, then, else, items, contains,
additionalProperties, propertyNames, unevaluatedItems, unevaluatedProperties,
contentSchema, the branches of allOf, anyOf, oneOf, and prefixItems, and the member values
of properties, patternProperties, $defs, and dependentSchemas. Nothing else is descended. The
object that is the value of properties, patternProperties, $defs, or dependentSchemas is a
container, not a schema: its keys are member names, so a property literally named type or allOf is
read as a name and never as a keyword. A boolean true/false schema is a legal subschema and is
reached, but neither pass has a keyword to read on one.
This matters when an override fragment carries JSON data. Draft 2020-12 treats an unrecognized
keyword as an annotation — arbitrary data — and the values of default, const, enum, and
examples are data even though the keywords are defined. Such a value is never read as a schema: an
object in a default position keeps every member it declares, including a minimum the numeric
filter would otherwise strip, and an object in a const position that happens to look like an
unsatisfiable schema does not fail generation. A definitions member is data for the same reason —
this generator publishes definitions under $defs, which a fragment may not declare, so a
definitions member can only have come from a fragment as annotation content.
The same rule decides which $ref targets are conjoined, because a @Schema(ref = "#/...") value
reaches the document verbatim and may point anywhere in it. A pointer is followed only when it
resolves to one of the positions listed above; a pointer at a data value or at a container object is
skipped exactly as an unresolvable one is. So @Schema(ref = "#/$defs/Money") and
@Schema(ref = "#/properties/amount") keep contributing their target’s type to the referring
location, while @Schema(ref = "#/default") neither fails generation on the data’s type nor lets
that type decide whether the referring location’s numeric keywords are suppressed.
Reaching a real subschema is unaffected: a genuine type conflict at any of the positions listed
above still fails generation.
Framework seams (INTERNAL)
Section titled “Framework seams (INTERNAL)”The types below are public only so sibling framework modules can call them. Their Javadoc marks them INTERNAL; they are not an application contract and sit outside this module’s application maturity promise. Applications must not construct or catch them.
AnnotationJsonSchemaGenerator
Section titled “AnnotationJsonSchemaGenerator”The single entry point. Construct with withVictoolsDefaults(), forInputProfile(profile), or
forOutputProfile(profile), then call generateCanonical(Type) for each type that needs a schema,
or describe(Type) for the same document paired with its RedactionManifest (see CanonicalSchema
below). An instance built by forOutputProfile(profile) also answers outputRenames(Type), the
list of published members whose schema name diverges from what the profile mapper serializes (see
OutputRename below); every other construction mode throws IllegalStateException from that call.
An instance built by either forInputProfile overload or by forOutputProfile(profile) answers
hiddenMembers(Type) for its own direction: the members and types its document describes that
carry @Hidden, @Schema(hidden = true), or both (see HiddenMember below and “Constraints and
common mistakes” above); an instance built by withVictoolsDefaults() throws
IllegalStateException from that call.
AnnotationJsonSchemaGenerator generator = AnnotationJsonSchemaGenerator.withVictoolsDefaults();String schemaJson = generator.generateCanonical(MyRequestBody.class);A profile-aware consumer supplies its already-resolved JsonMapperProfile and the direction that
matches the schema’s role:
AnnotationJsonSchemaGenerator inputGenerator = AnnotationJsonSchemaGenerator.forInputProfile(resolvedProfile);String argumentSchema = inputGenerator.generateCanonical(toolArgumentType);CanonicalSchema
Section titled “CanonicalSchema”The record describe(Type) returns: the canonical document text (json()) paired with the
RedactionManifest bound to it (redactionManifest()). json() equals generateCanonical(Type)’s
output for the same type only for an instance describe produced: the record is public and anyone
can construct one, pairing any text with any manifest, so trust the pairing only when
redactionManifest().matches(json()) holds. Both components are required; the compact constructor
rejects null.
CanonicalSchema described = generator.describe(MyRequestBody.class);String schemaJson = described.json(); // == generator.generateCanonical(MyRequestBody.class)RedactionManifest manifest = described.redactionManifest();RedactionManifest
Section titled “RedactionManifest”The sorted RFC 6901 pointers of every reserved-name guard in one CanonicalSchema’s document, bound
to the SHA-256 digest of that document’s canonical bytes. Only this package constructs one — no
public or protected constructor, and the class is final — so a caller cannot forge a manifest.
CanonicalSchema, however, is a plain record anyone can construct, so a consumer trusts the pairing
only when redactionManifest().matches(json()) holds; a borrowed manifest fails that check for
different JSON. pointers() is unmodifiable and may be empty; digest() is "sha-256:" plus 64
lowercase hexadecimal characters; matches(schemaJson) parses, canonicalizes, digests, and
compares, returning false for any unparsable or malformed input — a document repeating a key in
one object included — and throwing NullPointerException for a null argument. toString() prints the pointers and the digest only — a pointer names a
schema location, never a reserved name, and no schema content is printed.
OutputRename
Section titled “OutputRename”One member outputRenames(Type) (on a forOutputProfile generator only) reports: declaringType
is the binary class name (Class#getName()) declaring the member, member is the Java member’s own
name (java.lang.reflect.Member#getName()), serializedName is the name the profile mapper’s
serialization introspection gives it, and schemaName is the property name the output document
publishes it under. The record carries names only, never a schema fragment. OpenAPI publication consumes
this shape; components evolve only by appending nullable trailing fields.
AnnotationJsonSchemaGenerator outputGenerator = AnnotationJsonSchemaGenerator.forOutputProfile(profile);List<OutputRename> renames = outputGenerator.outputRenames(MyResponseBody.class);// renames is empty unless some published member's schema name differs from its serialized nameHiddenMember
Section titled “HiddenMember”One entry hiddenMembers(Type) reports: declaringType is the binary class name
(Class#getName()) of the class declaring the member, or of the type itself; member is the Java
member’s own name (java.lang.reflect.Member#getName()), the name of a field, a method, or an enum
constant, a creator parameter’s <init>#i or method#i name, or null when the entry reports the
type itself (the component is annotated jakarta.annotation.Nullable); marker is the
HidingMarker the declaration carries; hideableBySchemaHidden says whether declaring
@Schema(hidden = true) on the property’s own field or getter would leave it out (see
“Constraints and common mistakes” above for the Stable position rules). The record carries names and flags only, never a schema
fragment.
AnnotationJsonSchemaGenerator inputGenerator = AnnotationJsonSchemaGenerator.forInputProfile(resolvedProfile);List<HiddenMember> hidden = inputGenerator.hiddenMembers(MyRequestBody.class);// empty unless a described member or type carries @Hidden or @Schema(hidden = true)HidingMarker
Section titled “HidingMarker”The enum of a HiddenMember’s markers: HIDDEN for @Hidden alone, SCHEMA_HIDDEN for
@Schema(hidden = true) alone, BOTH for the two together.
JsonSchemaGenerationException
Section titled “JsonSchemaGenerationException”The one bounded failure type this module throws, with a value-free, length-bounded message and the original cause preserved when one exists. Consumers catch and translate this exception without depending on any Victools exception type.
getMessage() is safe to log verbatim. It is at most 512 UTF-16 code units — a hard bound, not an
approximation — and carries no code point that could terminate a log record, forge a second one, or
reorder the identity it renders: every Unicode Cc control (including the C1 block and NEL), Cf
format character (including the Trojan-Source bidirectional overrides and isolates, SOFT HYPHEN,
and the byte-order mark), Zl, Zp, and unpaired surrogate is replaced one-for-one with ? before
the message is bounded, and an elision never splits a surrogate pair. Identity a type, property, or
profile contributed therefore stays readable while a hostile name can neither inject a line break
nor make the message read as naming a different type or profile than the one that failed. The one
stated limitation: a supplementary-plane Cf code point (U+110BD, U+1D173–U+1D17A) survives, since
none of them reorders or terminates rendered log text.
That guarantee covers the message only. The attached cause is not sanitized or bounded — it is
preserved raw, deliberately, because it is what makes a failure diagnosable. A conventional
log.error("…", ex) renders Caused by: <cause message>, which may be arbitrary third-party or
application text. Log the cause where that is acceptable; log getMessage() alone where it is not.
Extension Points
Section titled “Extension Points”None for applications. This module exposes a closed generation surface — no schema-generator SPI,
custom Victools module registration, or configuration key. A framework consumer that needs a custom
wire shape for an exact Java class declares it once on its JsonMapperProfile through
dev.vertique.core.json.JsonSchemaTypeOverride, which this module’s profile-aware construction
modes consume.
Module Dagger Bindings
Section titled “Module Dagger Bindings”None. vertique-json-schema is a plain library artifact: applications construct
AnnotationJsonSchemaGenerator through its static factories. There is no Dagger @Module.
Dependencies
Section titled “Dependencies”| Artifact | Scope | Purpose |
|---|---|---|
dev.vertique:vertique-core |
compile | JsonMapperProfile, JsonSchemaFragment, JsonSchemaTypeOverride — the stable JSON profile contracts this module consumes |
com.fasterxml.jackson.core:jackson-databind |
compile | ObjectMapper property discovery that Victools’ Jackson module introspects |
jakarta.validation:jakarta.validation-api |
compile | Jakarta Validation constraint annotations Victools’ Jakarta Validation module introspects; also the Validator/constraint-metadata types forInputProfile(profile, Validator)’s metadata constraint source reads. No hibernate-validator (or any other provider implementation) dependency — Hibernate’s @Length/@Range/@URL are recognized by fully-qualified annotation class name, never by simple name alone, keeping this module provider-agnostic |
io.swagger.core.v3:swagger-annotations-jakarta |
compile | @Schema / @ArraySchema annotations Victools’ Swagger 2 module introspects |
com.github.victools:jsonschema-generator |
compile | The Draft 2020-12 schema generation engine |
com.github.victools:jsonschema-module-jackson |
compile | Jackson property discovery module |
com.github.victools:jsonschema-module-jakarta-validation |
compile | Jakarta Validation constraint mapping module |
com.github.victools:jsonschema-module-swagger-2 |
compile | Swagger 2 annotation mapping module |