Skip to content

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.

boolean ok = JsonSchemaCompatibilityChecker.isBackwardCompatible(originalJson, updatedJson);

DiffContext ctx = JsonSchemaCompatibilityChecker.checkBackwardCompatibility(
        originalJson, updatedJson);
JsonSchemaCompatibilityChecker checker = JsonSchemaCompatibilityChecker.builder().build();

CompatibilityCheckResult result = checker.checkBackward(originalJson, updatedJson);
boolean ok = result.isCompatible();
Set<Difference> blocking = result.getIncompatibleDifferences();

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:

JsonSchemaCompatibilityChecker.builder().dereferencer(dereferencer).build();

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:

public interface Document extends Node { }

public interface OpenApiDocument extends Document {
    OpenApiInfo getInfo();
    void setInfo(OpenApiInfo value);
    OpenApiInfo createInfo();
}
public interface Document extends Node {
    Info getInfo();
    void setInfo(Info value);
    Info createInfo();
}

public interface OpenApiDocument extends Document { /* declares none of them */ }

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.

- RootNode root = node.root();
+ RootCapable root = node.root();

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. Use detach() to remove a node from its parent.
  • setParent became _setParent on NodeImpl, alongside _setParentPropertyName and _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 JSchemaJFullSchema 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:

+ root:
+   type: Document
+
  entities:
    - name: Document
-     root: true

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:

  • CollectionUtilcopyOfList / copyOfSet / copyOfMap, replacing List.copyOf and java.util.Collections. Distinct names because JSweet cannot overload by parameter type.
  • NumberUtil.compare — replacing BigDecimal comparison.
  • 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:

JAVA_HOME=/usr/lib/jvm/java-21-temurin-jdk mvn clean install -Ptranspilation

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.