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

Implements document-scoped $dynamicRef resolution per JSON Schema 2020-12 §8.2.3.2. Bare $dynamicRef schemas (no $ref) now deserialize as OpenApiSchemaReference whose Target resolves via per-document $dynamicAnchor and $anchor registries in OpenApiWorkspace.

Resolution order in Target

  1. $dynamicAnchor index — single candidate resolves automatically
  2. $anchor fallback — when zero $dynamicAnchor candidates exist (per §8.2.3.2)
  3. null — when ambiguous (multiple candidates need dynamic-scope tracking)

Type of Change

  • New feature (non-breaking change which adds functionality)

Public APIs for consumers tracking dynamic scope

  • GetDynamicAnchorCandidates(doc, anchorName) — returns all candidate schemas
  • ResolveDynamicAnchorInContext(contextSchema, anchorName) — resolves against a specific schemas $defs

Behavior change: $ref siblings now take precedence

The $ref deserializer path now uses ApplySchemaMetadata (which populates JsonSchemaReference properties) instead of SetMetadataFromJsonObject. Structural siblings on $ref schemas (type, maxProperties, pattern, allOf, etc.) are now captured and take precedence over target values via the existing Reference.X ?? Target?.X property getters. Previously these siblings were stored but not surfaced. This affects all $ref schemas with siblings in 3.1+ documents, not just $dynamicRef.

Anchor index coverage

Registries are populated by recursively walking the entire document tree: component schemas, reusable component definitions (parameters, responses, request bodies, headers, callbacks, path items, media types), inline schemas (paths, operations, webhooks), and all nested subschema locations ($defs, properties, items, allOf, if/then/else, etc.).

Open question

When multiple schemas declare the same $dynamicAnchor, Target returns null (ambiguous). The spec requires the outermost candidate in the dynamic evaluation scope. Automatic resolution would require threading evaluation context through Target (e.g., AsyncLocal<Stack>). Currently consumers handle this via GetDynamicAnchorCandidates + ResolveDynamicAnchorInContext. Feedback welcome on whether the library should handle this automatically or leave it to consumers.

Testing

  • Unit tests added/updated
  • All existing tests pass (except 7 pre-existing Serialize_DoesNotMutateDom failures on clean main)

46 dynamic-ref tests across V31 and V32 covering: bare $dynamicRef deserialization, resolution to anchors in all schema locations, ambiguity handling, $anchor fallback, $dynamicAnchor precedence, round-trip serialization, sibling preservation, context-aware resolution, and $ref regression.

Microsoft.OpenApi.Tests: 1149 passed. Microsoft.OpenApi.Readers.Tests: 535 passed, 7 pre-existing failures.

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)

Related

@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 3 times, most recently from e4c3bd2 to 96e2bcc Compare June 28, 2026 12:23
@aqeelat aqeelat marked this pull request as draft June 29, 2026 00:22
@aqeelat aqeelat force-pushed the feat/dynamicref-resolution branch from 96e2bcc to 573f8bf Compare June 29, 2026 09:20
@aqeelat aqeelat marked this pull request as ready for review June 29, 2026 09:38
@aqeelat

aqeelat commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

@baywet sorry for the mega PR. Most of the changes are tests and mechanical changes. However, if you want, I can split it into multiple PRs if that will make it easier for you to review.

@baywet

baywet commented Jun 29, 2026

Copy link
Copy Markdown
Member

Thanks for the contribution!

No worries, I'll take the time to review during the week.

Looking back at the reference mechanisms that are implemented throughout the library, I realized we had a specification/documentation page that never got published on release. Took the time to clean it up a little and it's now available here.

https://learn.microsoft.com/en-us/openapi/openapi.net/references-openapi

Let us know what you think!

/// </summary>
public static string? ExtractDynamicAnchorName(string? dynamicRef)
{
if (string.IsNullOrEmpty(dynamicRef) || dynamicRef is null) return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Removed the redundant || dynamicRef is null and used dynamicRef! on the subsequent dereference. The redundant check was a workaround for netstandard2.0 nullable flow analysis; the null-forgiving operator achieves the same without the dead conditional.

Comment on lines +237 to +239
foreach (var parameter in components.Parameters.Values)
if (parameter is not null)
RegisterParameterAnchors(document, parameter);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. These foreach + if (x is not null) patterns match the existing RegisterComponents code style throughout the file. Converting to .Where() would add LINQ iterator allocations in a one-time registration path for no runtime benefit. Leaving as-is for consistency with the existing codebase.

Comment on lines +242 to +244
foreach (var response in components.Responses.Values)
if (response is not null)
RegisterResponseAnchors(document, response);
Comment on lines +247 to +249
foreach (var requestBody in components.RequestBodies.Values)
if (requestBody is not null)
RegisterRequestBodyAnchors(document, requestBody);
Comment on lines +252 to +254
foreach (var header in components.Headers.Values)
if (header is not null)
RegisterHeaderAnchors(document, header);
Comment on lines +327 to +329
foreach (var response in op.Responses.Values)
if (response is not null)
RegisterResponseAnchors(document, response);
Comment on lines +332 to +334
foreach (var callback in op.Callbacks.Values)
if (callback is not null)
RegisterCallbackAnchors(document, callback, visited);
Comment on lines +351 to +353
foreach (var header in response.Headers.Values)
if (header is not null)
RegisterHeaderAnchors(document, header);
Comment on lines +366 to +373
foreach (var mediaType in content.Values)
{
if (mediaType is null) continue;
if (mediaType.Schema is not null)
RegisterAnchors(document, mediaType.Schema);
if (mediaType.ItemSchema is not null)
RegisterAnchors(document, mediaType.ItemSchema);
}
Comment on lines +625 to +627
foreach (var def in contextSchema.Definitions.Values)
if (def.DynamicAnchor is string da && da.Equals(anchorName, StringComparison.Ordinal))
return def;

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds document-scoped $dynamicRef resolution for OpenAPI 3.1/3.2 JSON Schema 2020-12 by indexing $dynamicAnchor/$anchor declarations per OpenApiDocument in OpenApiWorkspace, and by deserializing bare $dynamicRef schemas as OpenApiSchemaReference so they participate in the existing reference-resolution pipeline.

Changes:

  • Index $dynamicAnchor and $anchor declarations per document in OpenApiWorkspace and expose APIs for candidate lookup + context-aware resolution.
  • Update V31/V32 schema deserializers to create an OpenApiSchemaReference for bare $dynamicRef (no $ref) and preserve siblings via ApplySchemaMetadata.
  • Add extensive V31/V32 unit tests covering registration, resolution precedence, ambiguity behavior, serialization round-trips, and tricky schema-bearing locations (responses/headers/callback cycles/etc.).

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs Adds OpenAPI 3.2 test coverage for bare $dynamicRef deserialization, resolution, anchor registration across OAS locations, and serialization behavior.
test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs Adds OpenAPI 3.1 test coverage for anchor indexing across subschema locations, ambiguity behavior, context-aware resolution, and $ref regression scenarios.
src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Implements per-document $dynamicAnchor/$anchor registries and recursive anchor discovery across components + inline paths/webhooks/callback graphs.
src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs Deserializes bare $dynamicRef schemas into OpenApiSchemaReference and applies schema metadata so siblings are preserved.
src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs Same as V32, for OpenAPI 3.1 schema deserialization.
src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs Adds $dynamicRef pointer detection and helper methods for extracting anchor names + determining fragment-only refs.
src/Microsoft.OpenApi/PublicAPI.Unshipped.txt Records new/changed public API surface for serialization overrides, Target override, and new workspace APIs.
src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs Overrides Target to resolve bare $dynamicRef via workspace $dynamicAnchor index with $anchor fallback when appropriate.
src/Microsoft.OpenApi/Models/JsonSchemaReference.cs Adds IsDynamicRefOnly and custom serialization behavior to emit $dynamicRef (without $ref) for dynamic-ref-only references, plus interface updates for anchor walking.

Comment on lines +471 to +488
if (dynamicPointer != null)
{
var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer);
var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument);
var referenceMetadata = new OpenApiSchema();
jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
static (schema, name, value) =>
{
if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal))
{
schema.UnrecognizedKeywords ??= new Dictionary<string, JsonNode>(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
}
});
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
result.Reference.IsDynamicRefOnly = true;
return result;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added result.Reference.SetJsonPointerPath(dynamicPointer, nodeLocation) before returning, mirroring the $ref branch.

Comment on lines +471 to +488
if (dynamicPointer != null)
{
var anchorName = JsonNodeHelper.ExtractDynamicAnchorName(dynamicPointer);
var result = new OpenApiSchemaReference(!string.IsNullOrEmpty(anchorName) ? anchorName! : dynamicPointer, hostDocument);
var referenceMetadata = new OpenApiSchema();
jsonObject.ParseMap(referenceMetadata, _openApiSchemaFixedFields, _openApiSchemaPatternFields, hostDocument, context,
static (schema, name, value) =>
{
if (!string.Equals(name, OpenApiConstants.DynamicRef, StringComparison.Ordinal))
{
schema.UnrecognizedKeywords ??= new Dictionary<string, JsonNode>(StringComparer.Ordinal);
schema.UnrecognizedKeywords[name] = value;
}
});
result.Reference.ApplySchemaMetadata(referenceMetadata, jsonObject);
result.Reference.IsDynamicRefOnly = true;
return result;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Same change applied to V32.

Comment on lines +597 to +603
public IReadOnlyList<IOpenApiSchema> GetDynamicAnchorCandidates(OpenApiDocument hostDocument, string anchorName)
{
if (_dynamicAnchorRegistryByDocument.TryGetValue(hostDocument, out var anchors) &&
anchors.TryGetValue(anchorName, out var candidates))
return candidates;
return [];
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Now returns candidates.AsReadOnly().

Comment on lines +523 to +525
// A $dynamicRef alongside structural schema keywords must not drop the siblings. The object
// is parsed as a normal OpenApiSchema (preserving maxProperties and properties) rather than
// being reduced to a bare reference that loses them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Updated comment to reflect that the object is parsed as an OpenApiSchemaReference with siblings preserved via ApplySchemaMetadata.

@baywet baywet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution!

In addition to the comments I've left, I think we have good coverage of the deserialization scenarios, and some coverage of the serialization scenarios (at least for basic properties).
In think that one key aspect that is missing from the tests is: people using the object model to build documents. For "regular refs" components registration is required. I'd like to see more tests demonstrating that a document built from OM can resolve dynamic refs without requiring a re-parsing.

/// This class extends OpenApiReference to provide schema-specific metadata override capabilities.
/// </summary>
public class JsonSchemaReference : OpenApiReferenceWithDescription
public class JsonSchemaReference : OpenApiReferenceWithDescription, IOpenApiSchemaMissingProperties

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what was the motivation for adding this interface?
I think this is going to add confusion, essentially, only OpenApiSchemaReference and OpenApiSchema should provide that service to the consumers. The only reason why this class is public and not internal is because it's a generic type argument for another public class.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted. I added it to share an EnumerateMissingPropertiesChildren helper between the two EnumerateChildren overloads in OpenApiWorkspace, but you're right that it adds confusion. Restored the separate overload that reads JsonSchemaReference properties directly.

Assert.True(reference.Reference.IsDynamicRefOnly);
// External target is not loaded in this workspace, so resolution returns null rather than
// falling back to the local Tree anchor.
Assert.Null(reference.Target);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we update this unit test to provide a second document and ensure the resolution of externals also works?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. External $dynamicRef resolution (cross-document via URI) requires finding the target document by URI and looking up its anchor registry. The current Target override only resolves fragment-only refs (#node) against the local document. I can add cross-document support in this PR if you want it now, or track it as a follow-up. Let me know which you prefer.

…chemaReference

Implements document-scoped $dynamicRef resolution per JSON Schema
2020-12 §8.2.3.2. Bare $dynamicRef schemas (no $ref) now deserialize
as OpenApiSchemaReference whose Target resolves via per-document
$dynamicAnchor and $anchor registries in OpenApiWorkspace.

Resolution order in Target:
1. $dynamicAnchor index (single candidate → resolved automatically)
2. $anchor fallback when zero $dynamicAnchor candidates exist (per §8.2.3.2)
3. null when ambiguous (multiple candidates need dynamic-scope tracking)

Anchor registries are populated by recursively walking the entire
document tree: component schemas, reusable component definitions
(parameters, responses, request bodies, headers, callbacks, path items,
media types), inline schemas (paths, operations, webhooks), and all
nested subschema locations ($defs, properties, items, allOf, if/then/else,
etc.).

Public APIs for consumers tracking dynamic scope:
- GetDynamicAnchorCandidates(doc, anchorName): returns all candidate schemas
- ResolveDynamicAnchorInContext(contextSchema, anchorName): resolves against
  a specific schema's $defs for context-dependent resolution

Other changes:
- Deserializer (V31/V32): detect bare $dynamicRef, create
  OpenApiSchemaReference with IsDynamicRefOnly, parse siblings via
  ApplySchemaMetadata
- JsonSchemaReference: add IsDynamicRefOnly flag, implement
  IOpenApiSchemaMissingProperties, override SerializeAsV31/V32 for
  dynamic-only refs
- JsonNodeHelper: GetDynamicReferencePointer, ExtractDynamicAnchorName,
  IsFragmentOnlyDynamicRef
- Siblings preserved via ApplySchemaMetadata and surfaced through
  existing Reference-first property getters

Fixes microsoft#2911.
@aqeelat aqeelat force-pushed the feat/dynamicref-resolution branch from 573f8bf to 06d3b50 Compare June 30, 2026 13:01
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.

Feature: Resolve $dynamicRef against $dynamicAnchor in the schema reference resolution pipeline

4 participants