Skip to content

feat(schema): resolve bare $dynamicRef via $dynamicAnchor index#2913

Open
aqeelat wants to merge 1 commit into
microsoft:mainfrom
aqeelat:feat/dynamicref-resolution
Open

feat(schema): resolve bare $dynamicRef via $dynamicAnchor index#2913
aqeelat wants to merge 1 commit into
microsoft:mainfrom
aqeelat:feat/dynamicref-resolution

Conversation

@aqeelat

@aqeelat aqeelat commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Description

Adds support for bare \$dynamicRef (no \$ref) by resolving it through a workspace-level \$dynamicAnchor index instead of the \$ref URI lookup, and fixes several correctness issues found during review of the initial implementation.

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)

Related Issue(s)

None filed.

Changes Made

  • Feature: bare \$dynamicRef schemas deserialize into an OpenApiSchemaReference whose Target resolves via a per-document \$dynamicAnchor index (OpenApiWorkspace). New JsonSchemaReference.IsDynamicRefOnly drives serialization that emits \$dynamicRef without \$ref.
  • fix: walk all nested subschemas when registering \$dynamicAnchor (previously only top-level + one level of \$defs; anchors under properties, items, allOf/anyOf/oneOf, if/then/else, etc. were missed). References are treated as leaves so targets aren't followed.
  • fix: scope \$dynamicAnchor resolution per document. The registry was workspace-global, so two documents in one workspace each declaring node (the conventional recursive-tree anchor) made resolution return null for both. Now keyed by OpenApiDocument.
  • fix: only fragment-only dynamic refs (#node) resolve against the local anchor index; URI-based refs (https://example.com#node) no longer reduce to a bare name and shadow an external target with a local same-named anchor.
  • fix: guard the dynamic-only Target path against an empty \$dynamicRef so it returns null instead of falling through to base.Target (which would resolve the anchor name stored in Reference.Id as a component id).
  • fix: preserve schema siblings (type, properties, maxProperties, allOf, \$defs, …) on \$dynamicRef objects. The bare-ref deserializer path returned before ParseMap, dropping siblings; now only a pure \$dynamicRef object becomes a reference, richer objects fall through to ParseMap.

Testing

  • Unit tests added/updated
  • All existing tests pass

New tests in OpenApiDynamicRefTests cover: bare-ref deserialization, no-\$ref serialization, anchor resolution (top-level, \$defs, nested properties, allOf), unknown-anchor → null, round-trip through serialization, coexistence with regular \$ref, per-document same-anchor resolution in a shared workspace, absolute-ref non-resolution, and sibling preservation.

Microsoft.OpenApi.Readers.Tests: 508 passed. Microsoft.OpenApi.Tests: 1147 passed. Microsoft.OpenApi builds clean on net8.0 and netstandard2.0.

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Versions applicability

  • My change applies to the version 3.X of the library, if so PR link: (this PR)
  • I have evaluated the applicability of the change against the other versions above.

This targets main (3.X) per the contributing guidance to start with the uppermost applicable version.

Additional Notes

  • Public API additions are recorded in PublicAPI.Unshipped.txt (JsonSchemaReference.SerializeAsV31/V32 overrides, OpenApiSchemaReference.Target override).
  • \$dynamicAnchor resolution here is intentionally a single-document, single-candidate match (returns null when a document declares the same anchor on multiple subschemas — true dynamic scope would require evaluation-path tracking, which is out of scope). Resolving $dynamicRef is also scoped per document per JSON Schema 2020-12, so two documents in a workspace may each declare the same anchor name.
  • Validation behavior: a $dynamicRef that cannot be resolved (unknown/ambiguous anchor, or an unloaded external URI) yields Target == null, which makes IOpenApiReferenceHolder.UnresolvedReference true. Because the validator only runs via an explicit Validate() call (not during LoadAsync), this surfaces only on explicit validation, where flagging an unresolvable $dynamicRef as an unresolved reference is the intended, defensible behavior.
  • For dynamic-only references, the anchor name is currently passed as the constructor referenceId and therefore lands in Reference.Id. It is never used for resolution (Target re-extracts the anchor from $dynamicRef) or serialization (the IsDynamicRefOnly path bypasses $ref/ReferenceV3 emission), so it is inert. Flagging in case reviewers prefer a dedicated field.
  • The pre-existing no-op OpenApiWorkspace(OpenApiWorkspace) copy constructor was left untouched; it is unused and a proper fix belongs in a separate change.

@aqeelat aqeelat requested a review from a team as a code owner June 27, 2026 14:42
@aqeelat aqeelat force-pushed the feat/dynamicref-resolution branch 2 times, most recently from 85e758d to 940c4d0 Compare June 27, 2026 19:53
…chemaReference

Bare $dynamicRef schemas (no $ref) now deserialize as OpenApiSchemaReference
whose Target resolves via a per-document $dynamicAnchor index in
OpenApiWorkspace. This implements document-scoped resolution per JSON
Schema 2020-12 §7.7.2 for the common recursive-type pattern.

Changes:
- OpenApiWorkspace: add per-document _dynamicAnchorRegistry, populated
  during RegisterComponents by recursively walking component schemas
  (including $defs, properties, items, allOf, etc.)
- Deserializer (V31/V32): detect bare $dynamicRef via
  GetDynamicReferencePointer, create OpenApiSchemaReference with
  IsDynamicRefOnly flag, parse siblings via ApplySchemaMetadata
- OpenApiSchemaReference: override Target to resolve via anchor index
  when IsDynamicRefOnly; return null (no $ref fallback) when anchor is
  not found or ambiguous
- JsonSchemaReference: add IsDynamicRefOnly flag, override
  SerializeAsV31/V32 to emit siblings without $ref for dynamic-only refs
- JsonNodeHelper: add GetDynamicReferencePointer,
  ExtractDynamicAnchorName, IsFragmentOnlyDynamicRef

Siblings ($defs, description, type, etc.) are preserved on the reference
via ApplySchemaMetadata and surfaced through the existing Reference-first
property getters.

Phase 1 of microsoft#2911.
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.

1 participant