Migrating from 3.x to 4.0
Version 4.0 rewrites JSON Schema support. Drafts 2019-09 and 2020-12 are supported alongside the older drafts, schemas from any draft can be converted to a single compound representation, and the compatibility checker and reference dereferencer were rebuilt around it. The entire JSON Schema API is now available in TypeScript as well as Java.
Most of the breaking changes below are in the JSON Schema API and were deliberate. A smaller set reached the OpenAPI, AsyncAPI and OpenRPC models as a consequence of the same work; those are called out in Model changes.
Staying on 3.x
The 3.1.x line remains under maintenance for fixes. Pinning to the latest 3.1.x release
is a supported option if you are not ready to upgrade.
This guide is organised by who is affected:
| Section | Read it if you… |
|---|---|
| Library API | call the library from application code |
| Model changes | hold model types in your own API, or implement visitors |
| Maintainer changes | work on Apitomy Data Models itself |
Library API
Changes to the API you call directly.
JSON Schema compatibility checking
JsonSchemaCompatibilityChecker moved from static methods to a builder and instance methods,
and the results are now typed objects rather than booleans and a diff context.
The mapping is direct:
| 3.x | 4.0 |
|---|---|
isBackwardCompatible(a, b) |
checkBackward(a, b).isCompatible() |
isForwardCompatible(a, b) |
checkForward(a, b).isCompatible() |
isFullyCompatible(a, b) |
checkFull(a, b).isFullyCompatible() |
checkBackwardCompatibility(a, b) |
checkBackward(a, b) |
Build the checker once and reuse it; it holds no per-check state.
CompatibilityCheckResult carries more than the old boolean:
result.isCompatible(); // the verdict
result.getDifferences(); // every difference found
result.getIncompatibleDifferences(); // only those that break compatibility
result.hasUnsupportedFeatures(); // true if the schema used something not yet modelled
result.getUnsupportedFeatures(); // and which keywords those were
checkFull returns a FullCompatibilityCheckResult, which exposes
isBackwardCompatible(), isForwardCompatible(), isFullyCompatible() and the two underlying
CompatibilityCheckResult objects.
Check hasUnsupportedFeatures()
A result can be isCompatible() == true while hasUnsupportedFeatures() is also true, so
treating the verdict alone as authoritative accepts comparisons that were never completed.
Gate on both. The 3.x API had no way to surface this.
The list is populated only when a dereferencer is configured, and holds the references it could not resolve — the sub-schemas behind those were never compared. See Schema Compatibility.
Cross-version checking is off by default and opt-in:
JsonSchemaCompatibilityChecker checker = JsonSchemaCompatibilityChecker.builder()
.allowCrossVersionChecking(true)
.build();
These classes were removed as part of the rewrite; they were internals of the old checker and
have no direct replacement: ArraySchemaDiff, NumberSchemaDiff, ObjectSchemaDiff,
StringSchemaDiff, SchemaAccessor, SchemaDiffVisitor.
JSON Schema reference dereferencing
New in 4.0. JsonSchemaRefDereferencer inlines $ref within a JSON Schema document, with
explicit control over depth and unresolvable references.
JsonSchemaRefDereferencer dereferencer = JsonSchemaRefDereferencer.builder()
.refResolver(myResolver)
.maxDepth(20)
.onUnresolvableRef(UnresolvableRefStrategy.COLLECT)
.build();
DereferenceResult result = dereferencer.dereference(schema);
JFullSchema inlined = result.schema();
List<String> unresolved = result.unresolvedRefs();
boolean cyclic = result.hasCycles();
UnresolvableRefStrategy.COLLECT gathers unresolvable references into the result;
UnresolvableRefStrategy.FAIL throws instead. Cycles are detected rather than followed — check
hasCycles() before consuming the schema, since cyclicRefs() names the participants.
The dereferencer can be handed to the compatibility checker so that comparison runs against fully inlined schemas:
Distinct from document dereferencing
This is separate from the document-level dereferencing described in
Dereferencing, which operates on OpenAPI and AsyncAPI
documents through Library. The two do not share an API.
Compound schema conversion
New in 4.0. A schema from any supported draft can be converted to a single compound
representation (ModelType.JC), so that code consuming schemas does not have to branch per
draft:
JsonSchema source = (JsonSchema) Library.readRootFromJSONString(schemaJson);
JsonSchema compound = CompoundSchemaConverter.toCompound(source, ModelType.JD7);
The second argument is the draft the source schema is written in — ModelType.JD4, JD6,
JD7, JM201909 or JM202012.
The per-draft classes (JD4ToCompoundConverter and friends) implement the conversion for each
draft and are dispatched to by toCompound. They are not intended as entry points.
JSON Schema in TypeScript
The JSON Schema packages were excluded from the TypeScript build in 3.x. In 4.0 they are
transpiled and exported from @apitomy/data-models, so JsonSchemaCompatibilityChecker,
JsonSchemaRefDereferencer and CompoundSchemaConverter are available to TypeScript callers
with the same shape as in Java.
Root document helpers
Library gained three methods for working with documents whose root need not be an object —
a JSON Schema root may be a boolean:
RootCapable root = Library.createRoot(modelType);
RootCapable root = Library.readRoot(objectNode);
RootCapable root = Library.readRootFromJSONString(jsonString);
Library.resolveNodePath
The second parameter widened from Document to Node, so a path can be resolved against any
node rather than only a document root:
- public static Node resolveNodePath(NodePath nodePath, Document doc)
+ public static Node resolveNodePath(NodePath nodePath, Node doc)
Existing calls passing a Document continue to compile unchanged.
Model changes
Changes to the generated model types. These matter if you hold model types in your own API signatures, or implement visitor interfaces directly.
Document.getInfo returns the base Info type
In 3.x each family narrowed the info accessors; in 4.0 they are declared once on the shared
Document interface:
Assignments that relied on the narrowed return type need a cast:
- OpenApiInfo info = doc.getInfo();
+ Info info = doc.getInfo(); // preferred
+ OpenApiInfo info = (OpenApiInfo) doc.getInfo(); // if the family type is genuinely needed
In practice the first form is almost always right. OpenApiInfo, AsyncApiInfo and
OpenRpcInfo are empty marker interfaces that add no members over Info — in 3.x as well as
4.0 — so the cast recovers a marker and nothing else.
setInfo no longer enforces the document family
setInfo accepts any Info on every document type, so
openApiDoc.setInfo(someAsyncApiInfo) now compiles where it previously did not. Nothing in
the library depends on this being rejected, but the compiler will no longer catch it for
you.
createInfo() is unaffected in practice: the implementation classes still return the
version-specific type (OpenApi31Info, AsyncApi30Info, and so on), so document construction
remains type-safe.
This change is a consequence of the root-declaration change described under
Maintainer changes. It applies only to info, the
one property common to all three document families. JSON Schema is unaffected.
RootNode is now RootCapable
A document root is no longer necessarily a node — a JSON Schema root may be a boolean. The
RootNode interface and RootNodeImpl class were replaced by RootCapable and
RootCapableImpl.
The reader and writer interfaces changed with it, on all OpenAPI, AsyncAPI and OpenRPC versions:
- RootNode readRoot(ObjectNode json) - ObjectNode writeRoot(RootNode node)
+ RootCapable readRoot(JsonNode json) + JsonNode writeRoot(RootCapable node)
This affects you only if you implement or call a *ModelReader / *ModelWriter directly.
Reading and writing through Library is unchanged.
Node split into Any and Node
Parent, root and attachment members moved from Node to a new Any supertype. Since
Node extends Any, code holding a Node sees no difference:
public interface Any {
boolean isNode();
Node parent();
String parentPropertyName();
ParentPropertyType parentPropertyType();
String mapPropertyName();
RootCapable root();
boolean isAttached();
void detach();
}
Two members did change:
attach(Node parent)was removed. Attachment is managed by the generated setters and collection methods; a node becomes attached when you set it onto a parent. Usedetach()to remove a node from its parent.setParentbecame_setParentonNodeImpl, alongside_setParentPropertyNameand_setParentPropertyType. The underscore marks these as internal — they are called by generated code and should not be called from application code.
Visitors gained afterVisit methods
Every visitor interface now declares an afterVisit<Type> method alongside each visit<Type>,
so a visitor can act on a node both before and after its children are traversed.
Adapters are unaffected
CombinedVisitorAdapter and the per-spec adapters provide empty implementations of every
method, old and new. If you extend an adapter — the documented approach in
Visitor Pattern — nothing changes.
If you implement a visitor interface directly, you must now implement roughly twice as many methods. Extending the adapter instead is the straightforward fix.
Traversal also gained subtree skipping. A visitor that implements TraversingVisitor is handed
a TraversalContext; calling skip() on it during a visit stops the traverser descending into
that node's children:
public class MyVisitor extends CombinedVisitorAdapter implements TraversingVisitor {
private TraversalContext context;
@Override
public void setTraversalContext(TraversalContext context) {
this.context = context;
}
@Override
public void visitComponents(Components node) {
this.context.skip(); // do not descend into this node's children
}
}
OpenRpcReferenceable was removed
An empty marker interface that nothing but OpenRpc1xReferenceable referenced; that interface
now extends Referenceable directly. Replace any reference to OpenRpcReferenceable with
OpenRpc1xReferenceable or Referenceable.
Union types moved out of models.union
In 4.0 the models.union package keeps only the generic union machinery — Union, UnionValue
and the primitive, list and map value types. Unions belonging to a particular specification moved
to that specification's package, and the shared ones moved to the model root.
3.x — …models.union |
4.0 |
|---|---|
MultiFormatSchemaSchemaUnion |
…models.asyncapi |
AnySchemaUnion |
…models |
BooleanSchemaUnion |
…models |
SchemaSchemaListUnion |
…models |
SchemaListUnionValue, …Impl |
…models |
ObjectUnionValue, …Impl |
renamed AnyUnionValue, …Impl (same package) |
The JSON Schema unions were reshaped by the rewrite rather than simply moved, following the
JSchema → JFullSchema renaming:
| 3.x | 4.0 |
|---|---|
BooleanJSchemaJSchemaListUnion |
…models.jsonschema.BooleanFullSchemaFullSchemaListUnion |
JSchemaListUnionValue, …Impl |
…models.jsonschema.FullSchemaListUnionValue, …Impl |
These are import-only changes for most callers — the types themselves behave as before. They are
easy to miss because they break at compile time in code that has nothing to do with JSON Schema:
anything touching AsyncAPI multi-format schemas imports MultiFormatSchemaSchemaUnion.
New JSON Schema model types
The rewrite added union types to the shared model namespace — Any, AnySchemaUnion,
BooleanSchemaUnion, SchemaListUnionValue, SchemaSchemaListUnion — and
MultiFormatSchemaSchemaUnion to AsyncAPI, which is how AsyncAPI 3.x multi-format schemas are
now represented. These are additions; nothing was removed to make room for them.
Per-version *DiffTraverser and *DiffVisitor types were also generated for every
specification version, supporting structural comparison of two documents of the same type.
Maintainer changes
Internal changes. Not needed to use the library, but worth knowing if you work on it.
Specifications declare their root type
The per-entity root: true flag was replaced by a top-level root: declaration in each
specification version file:
This is what enables a non-object root (JSON Schema declares type: JsonSchema, whose root may
be a boolean). It also has a side effect worth remembering: Document is now an ordinary
entity and so participates in cross-specification property hoisting like any other, which is
what moved getInfo onto the base Document interface.
Base classes
RootNode / RootNodeImpl were replaced by RootCapable / RootCapableImpl, and Any was
extracted from Node. New under visitors/: TraversalAction,
convert/AbstractConversionTraverser, and a diff/ package holding AbstractDiffTraverser,
CollectionDiff, PairingKey, PairingStrategyProvider and the default pairing strategies
that drive document diffing.
TypeScript transpilation
The JSON Schema packages are no longer excluded from the JSweet build. Bringing them in
required removing constructs that compile under javac but not JSweet, and added three
helpers that exist specifically to keep those workarounds in one place:
CollectionUtil—copyOfList/copyOfSet/copyOfMap, replacingList.copyOfandjava.util.Collections. Distinct names because JSweet cannot overload by parameter type.NumberUtil.compare— replacingBigDecimalcomparison.ResourceUtil.readResourceAsString— replacing classpath resource loading. Calls are substituted at transpile time with the file contents inlined, so the argument must be a string literal.
The full constraint list is in .claude/rules/jsweet-transpilation.md.
Verify on JDK 17 or 21
var crashes the transpiler on the JDKs CI builds with, but transpiles cleanly on JDK 25.
A green local build on a newer JDK proves nothing:
Generator snapshot
Templates under generator/src/main/resources/base/ are copied into generated output, so every
transpilation constraint applies to them. After changing one, regenerate the committed
snapshot and review the diff:
rm -rf generator/src/test/resources/io/apitomy/umg/synthetic/expected/
mvn -pl generator test -Dtest=SyntheticSnapshotTest
Branches
main is the 4.0 line. The 3.1.x branch carries the 3.1.x maintenance line and has its own
release workflow; fixes that apply to both must be landed on each.