Skip to content

feat(reference): dereference dynamic schema refs#5177

Open
aqeelat wants to merge 3 commits into
swagger-api:mainfrom
aqeelat:dynamic-ref
Open

feat(reference): dereference dynamic schema refs#5177
aqeelat wants to merge 3 commits into
swagger-api:mainfrom
aqeelat:dynamic-ref

Conversation

@aqeelat

@aqeelat aqeelat commented May 25, 2026

Copy link
Copy Markdown

Summary

Adds $dynamicRef / $dynamicAnchor dereferencing to the OpenAPI 3.1 and 3.2 dereference strategies in apidom-reference.

ApiDOM already parses these keywords into the element tree (via apidom-ns-json-schema-2020-12), but the dereference visitors only resolved $ref. Schemas using dynamic references passed through with $dynamicRef unresolved.

Closes #5176
Related: #378 (original 2021 request), #306 (JSON Schema 2020-12 dereferencing epic)

Context

The openapi-dynamicref-adoption-tracker tracks $dynamicRef support across the OpenAPI ecosystem. Its initial SDK snapshot confirmed that no tested generator preserves dynamic reference type fidelity — tools either fail to parse specs containing $dynamicAnchor, emit unknown/any for dynamic ref slots, or materialize generic templates as duplicate concrete types. This PR adds the parser-level dereferencing that downstream tools need.

Test fixtures are sourced from the tracker repo's petstore-dynamicref-showcase.yaml and its focused per-pattern fixtures.

Design decisions

1. Same merge/transclusion semantics as $ref

$dynamicRef schemas are transcluded inline with referencing-element properties merged on top. The $dynamicRef keyword is removed from the output. This matches the existing $ref behavior exactly.

2. Static target resolution before dynamic scope

The resolver first resolves the static target of $dynamicRef using the canonical URI selector (uriEvaluate), which matches schemas by $id within the current document. If the $dynamicRef fragment is a $dynamicAnchor token, the URI is stripped of its hash to locate the $id resource, then $dynamicAnchorEvaluate finds the anchor within that resource.

Only after the static target is resolved does the resolver walk ancestorsLineage (innermost-first) to check for a dynamic scope override. This ensures:

  • Embedded canonical $id targets are found without attempting external loads (important for resolve.external: false and offline use).
  • Missing external static targets (./missing.json#node) fail before dynamic scope can mask the broken reference with a coincidental local anchor match.

If the static target cannot be found in the current document (EvaluationJsonSchemaUriError), the resolver falls back to loading the URI as an external reference (matching the $ref code path).

3. DynamicScopeStack for spec-aligned dynamic scope resolution

The initial implementation walked ancestorsLineage (innermost-first) looking for a matching $dynamicAnchor. This heuristic was sufficient for simple cases but did not fully model the JSON Schema 2020-12 dynamic scope — particularly for schemas that use $defs to wrap $dynamicAnchor declarations without themselves containing $dynamicRef.

The DynamicScopeStack replaces that heuristic with an explicit scope stack:

  • DynamicScopeFrame — captured at each schema boundary that crosses into a new scope (e.g., $ref targets, $defs entries). Each frame stores the $dynamicAnchor declarations found in its subtree.
  • collectDynamicAnchors() — traverses an element tree and collects all $dynamicAnchor declarations (handles both namespace elements with $dynamicAnchor as a direct property and generic ObjectElements using get('$dynamicAnchor')).
  • DynamicScopeStack.resolveDynamicRef() — walks frames from index 0 (outermost) per JSON Schema 2020-12 §8.5.2; first match wins.
  • toDynamicScopeStack() — builds the inherited scope by cloning the parent visitor's stack and adding ancestor frames matching the isDynamicScopeCandidate filter.
  • hasDynamicScopeOverride guard — forces traversal of schemas that have $dynamicAnchor or $defs (even without $dynamicRef) so their $dynamicAnchor declarations enter the scope.

The stack is cloned for each nested visitor, so different traversal branches maintain independent scopes — matching the spec's lexical scoping semantics.

4. Fallback to external reference loading

When the static target is not found in the current document, the resolver loads the $dynamicRef URI as an external reference and searches the returned resource for the anchor (or evaluates the JSON Pointer). This handles the case where $dynamicRef is used without any ancestor override — it behaves like a plain $ref to an anchor.

5. $ref takes precedence

If a schema has both $ref and $dynamicRef, the existing $ref handling runs unchanged. $dynamicRef only activates when $ref is absent. This preserves backward compatibility and avoids changing any existing $ref code paths.

6. Full metadata preservation

All dereference metadata is carried through identically to $ref:

  • ref-fields: $dynamicRef and $dynamicRefBaseURI
  • ref-origin: source URI of the resolved element (OAS 3.2 uses $self value when present)
  • ref-referencing-element-id: identity of the referencing schema
  • ancestorsSchemaIdentifiers: used by resolveSchema$dynamicRefField to compute the base URI

7. Circular reference handling

Circular dynamic references are detected and handled the same way as circular $ref:

  • Direct self-reference check (referencingElement === referencedElement)
  • Max-depth check (dereference.maxDepth)
  • circular: 'error' — throws
  • circular: 'replace' — inserts a RefElement with $dynamicRef metadata
  • circular: 'ignore' — continues

8. External $dynamicRef support

Cross-file dynamic references are resolved through the existing toReference mechanism. The resolver respects:

  • resolve.internal / resolve.external flags
  • skipNestedExternal option
  • getNestedVisitorOptions updated to check $dynamicRef (not just $ref) for internal reference detection

9. continueOnError support

resolveSchema$dynamicRef uses handleDereferenceError throughout, enabling continueOnError: true to collect errors from non-existent anchors, missing external files, and circular references without aborting the entire dereference pass.

10. Boolean JSON Schema support

Boolean schemas (true / false) encountered as dynamic ref targets are handled with the same clone-and-annotate approach as $ref.

11. this.constructor pattern for visitor instantiation

All nested visitor creation sites (in both resolveSchema$ref and resolveSchema$dynamicRef) use the this.constructor pattern instead of direct new OpenAPI3_xDereferenceVisitor(...). This ensures subclass compatibility — if the visitor is extended, nested visitors will also be instances of the subclass.

12. Symmetric for OAS 3.1 and 3.2

Identical logic in both strategies. The OAS 3.2 visitor additionally carries $selfValue through nested visitors for external references (matching its existing $ref behavior).

Test coverage

1280 tests passing (37 new tests added for $dynamicRef).

Integration tests (both OAS 3.1 and 3.2)

Fixture Pattern Verified
$dynamicRef-internal $dynamicRef → sibling schema $dynamicAnchor in same document Transclusion + anchor match
$dynamicRef-external $dynamicRef$dynamicAnchor in external file Cross-file resolution + skipNestedExternal
$dynamicRef-embedded-canonical-id $dynamicRef → schema with $id + $dynamicAnchor in same document Resolves via uriEvaluate without external load, works with resolve.external: false
$dynamicRef-missing-static-target $dynamicRef → non-existent external file Errors before dynamic scope can mask the broken reference
$dynamicRef-fallback $dynamicRef with no ancestor override Falls back to document-level anchor search
$dynamicRef-recursive Self-referential $dynamicRef$dynamicAnchor Circularity detection (same element reference)
$dynamicRef-nested-scope Nested $dynamicAnchor at multiple levels Resolves to innermost $dynamicAnchor per JSON Schema 2020-12 §7.7.3
$dynamicRef-boolean-json-schema $dynamicRef → boolean JSON Schema Clone-and-annotate handling
$dynamicRef-non-existent-anchor $dynamicRef → non-existent anchor Error propagation
$dynamicRef-circular-error Circular $dynamicRef with circular: 'error' Throws on circularity
$dynamicRef-circular-replace Circular $dynamicRef with circular: 'replace' Inserts RefElement placeholder
$dynamicRef-max-depth Nested $dynamicRef with maxDepth MaximumDereferenceDepthError
$dynamicRef-static-anchor $dynamicRef targeting ordinary $anchor (not $dynamicAnchor) Behaves like $ref, no dynamic scope
$dynamicRef-ignore-internal $dynamicRef with resolve: { internal: false } Internal refs left unresolved
$dynamicRef-ignore-external $dynamicRef with resolve: { external: false } External refs left unresolved
$dynamicRef-continue-on-error-anchor $dynamicRef → non-existent anchor + continueOnError Error collected, dereference continues
$dynamicRef-continue-on-error-missing $dynamicRef → missing external file + continueOnError Error collected, dereference continues
$dynamicRef-continue-on-error-circular Circular $dynamicRef + circular: 'error' + continueOnError Error collected, dereference continues
$dynamicRef-external-scope-override External $dynamicRef with root doc $dynamicAnchor override Runtime polymorphism across file boundaries
$dynamicRef-self-referencing $dynamicAnchor + $dynamicRef on same element Direct self-reference detection
$dynamicRef-ancestor-id $dynamicRef referencing $id-qualified $dynamicAnchor Base URI resolution via ancestorsSchemaIdentifiers
$dynamicRef-wrapper-defs Deeply nested $defs with $dynamicAnchor at each level DynamicScopeStack resolves through wrapper layers
$dynamicRef-wrapper-ref-template $ref-entered template with caller override Outer scope $dynamicAnchor overrides template default

OAS 3.2-only

Fixture Pattern Verified
$dynamicRef-self-deref $dynamicRef + $self on root OpenAPI document ref-origin uses $self value

Unit tests (both OAS 3.1 and 3.2)

$dynamicAnchor selector: isDynamicAnchor, uriToDynamicAnchor, parse, evaluate — valid/invalid anchors, URI extraction, error types.

Not wired

Fixture Status
$dynamicRef-showcase Fixture exists for both strategies; has pre-existing JSON Pointer errors during $ref resolution that prevent wiring

Commit structure

Commit Description
fbb0448 feat(reference): add $dynamicRef dereferencing for OAS 3.1 and 3.2 — implementation (11 files)
83b7430 test(reference): add comprehensive $dynamicRef test coverage — tests and fixtures (85 files)
a6751ca feat(reference): implement DynamicScopeStack for spec-aligned $dynamicRef resolution — replaces ad-hoc scope walk (13 files)

Validation

npm run build
npm test          # 1280 passing
npm run lint
npm run typescript:check-types

Future work

  • Showcase fixture: The $dynamicRef-showcase fixture has pre-existing JSON Pointer errors during $ref resolution that prevent wiring. Fixing those is orthogonal to this PR.
  • Benchmarking: The apidom-reference package has no existing benchmark infrastructure. Performance data for $dynamicRef vs $ref dereferencing can be added in a follow-up.

aqeelat added 2 commits May 30, 2026 19:01
Implement full $dynamicRef/$dynamicAnchor dereferencing in the
openapi-3-1 and openapi-3-2 dereference strategy visitors.

- Add $dynamicAnchor selectors (isDynamicAnchor, uriToDynamicAnchor,
  parse, evaluate) with public API exports
- Add resolveSchema$dynamicRef method with static target resolution,
  dynamic scope walk (innermost-first per JSON Schema 2020-12 §7.7.3),
  and external reference fallback
- Support resolve.internal/external flags, continueOnError, circular
  reference handling (error/replace/ignore), maxDepth, boolean JSON
  Schemas, allOf discriminator mapping, and ancestor $id base URI
- Apply this.constructor pattern for all nested visitor instantiation
  in both resolveSchema$ref and resolveSchema$dynamicRef
- OAS 3.2 visitor carries $selfValue through nested visitors

Closes swagger-api#5176
33 new tests covering all code paths in resolveSchema$dynamicRef
(1276 total passing). Both OAS 3.1 and OAS 3.2 strategies tested.

Integration tests: internal/external $dynamicRef, embedded canonical
$id, missing static target, fallback, recursive, nested scope,
static anchor, boolean JSON Schema, circular (error/replace/ignore),
max depth, allOf+discriminator, resolve flags (internal/external),
continueOnError (anchor/missing/circular), external scope override,
direct self-reference, ancestor $id base URI, OAS 3.2 $self.

Unit tests: $dynamicAnchor selectors (isDynamicAnchor,
uriToDynamicAnchor, parse, evaluate) for both strategies.
…cRef resolution

Replace the ad-hoc [indirections, ...ancestorsLineage] scope walk in
resolveSchema$dynamicRef() with a full explicit DynamicScopeStack that
tracks $dynamicAnchor declarations across nested schema scopes per the
JSON Schema 2020-12 specification.

Key changes:
- Add DynamicScopeStack class with push/pop/resolveDynamicRef/clone
- Wire dynamicScopeStack into OAS 3.1 and 3.2 dereference visitors
- Add toDynamicScopeStack helper to build inherited scope from ancestors
- Add hasDynamicScopeOverride guard for schemas with $dynamicAnchor/$defs
- Export DynamicScopeStack and related types from package index
- Add $dynamicRef-wrapper-defs and $dynamicRef-wrapper-ref-template test
  fixtures for both OAS 3.1 and OAS 2
- Fix $dynamicAnchor selector test lint issues

All 1280 tests passing, type-check and lint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add $dynamicRef dereference support for OpenAPI 3.1 and 3.2 strategies

1 participant