Skip to content

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

Open
aqeelat wants to merge 3 commits into
microsoft:mainfrom
aqeelat:feat/dynamicref-resolution
Open

feat(schema): resolve bare $dynamicRef via $dynamicAnchor index#2913
aqeelat wants to merge 3 commits 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 schema's $defs

$ref sibling behavior

This PR relies on the existing OpenAPI.NET 3.1/3.2 behavior where $ref schema siblings are preserved on JsonSchemaReference and surfaced through OpenApiSchemaReference's existing reference-first property getters.

This PR does not introduce that behavior. It extends the same storage model to bare $dynamicRef schemas so sibling keywords are preserved while the schema participates in reference resolution.

See #2919 for a broader discussion on whether the reference-first getter model should be revised for assertion keywords.

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, OM-built document, 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!

Comment thread src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed

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 thread src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs
Comment thread src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs
Comment thread test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs Outdated

@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.

Comment thread src/Microsoft.OpenApi/Models/JsonSchemaReference.cs Outdated
@aqeelat aqeelat force-pushed the feat/dynamicref-resolution branch from 573f8bf to 06d3b50 Compare June 30, 2026 13:01
@aqeelat

aqeelat commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@baywet CodeQL is suggesting we convert the foreach + if (x is not null) patterns in OpenApiWorkspace to .Where() and FirstOrDefault. The trade-off: .Where() reads cleaner but allocates an iterator per call site; foreach + if iterates directly with zero allocations. Negligible in a one-time registration path like RegisterComponents. Should we convert for readability or keep as-is?

@baywet

baywet commented Jul 2, 2026

Copy link
Copy Markdown
Member

@baywet CodeQL is suggesting we convert the foreach + if (x is not null) patterns in OpenApiWorkspace to .Where() and FirstOrDefault. The trade-off: .Where() reads cleaner but allocates an iterator per call site; foreach + if iterates directly with zero allocations. Negligible in a one-time registration path like RegisterComponents. Should we convert for readability or keep as-is?

Let's convert for consistency (we have patterns like those throughout the codebase). Allocation wise, the costliest part is the DOM itself and associated strings, the iterators are short lived and negligeable in comparison. And we've made significant performance improvements over the past year or so to have a better margin here.

@aqeelat aqeelat force-pushed the feat/dynamicref-resolution branch from 06d3b50 to 2ab57de Compare July 2, 2026 16:58
@aqeelat

aqeelat commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Done. Converted all foreach + if (x is not null) patterns to .Where() throughout OpenApiWorkspace.cs, and replaced the ResolveDynamicAnchorInContext loop with FirstOrDefault. Pushed in the latest force-push.

All review comments should be addressed now. Do you think this can be merged today?

Comment thread src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs Fixed
Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Fixed
@aqeelat

aqeelat commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@copilot fix failing test and address the other review items

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

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs Outdated
@baywet

baywet commented Jul 3, 2026

Copy link
Copy Markdown
Member

copilot fix failing test and address the other review items

@aqueelat, I believe this doesn't work on forks. (I had the same problem with aspnetcore recently)

@aqeelat aqeelat force-pushed the feat/dynamicref-resolution branch from 7a595ff to 702e385 Compare July 3, 2026 20:31
…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 2 times, most recently from b976475 to 5ea36ad Compare July 3, 2026 22:38
…micRef

The Serialize_DoesNotMutateDom tests use System.Text.Json to snapshot
the DOM before and after native serialization. Bare $dynamicRef schemas
now deserialize as OpenApiSchemaReference whose computed getters expose
the resolved target's properties, creating reflection cycles for
recursive dynamic refs. ReferenceHandler.Preserve lets STJ handle
cycles via $id/$ref metadata while still detecting DOM mutations.

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

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

Comment on lines +281 to +284
private void RegisterPathItemAnchors(OpenApiDocument document, IOpenApiPathItem pathItem, HashSet<object> visited)
{
if (!visited.Add(pathItem)) return;

Comment on lines +294 to +297
private void RegisterCallbackAnchors(OpenApiDocument document, IOpenApiCallback callback, HashSet<object> visited)
{
if (!visited.Add(callback)) return;

Comment on lines +330 to +331
private void RegisterRequestBodyAnchors(OpenApiDocument document, IOpenApiRequestBody requestBody)
=> RegisterMediaTypeSchemas(document, requestBody.Content);
Comment on lines +323 to +326
private void RegisterParameterAnchors(OpenApiDocument document, IOpenApiParameter parameter)
{
if (parameter.Schema is not null)
RegisterAnchors(document, parameter.Schema);
Comment on lines +333 to +336
private void RegisterResponseAnchors(OpenApiDocument document, IOpenApiResponse response)
{
RegisterMediaTypeSchemas(document, response.Content);
if (response.Headers is not null)
Comment on lines +341 to +344
private void RegisterHeaderAnchors(OpenApiDocument document, IOpenApiHeader header)
{
if (header.Schema is not null)
RegisterAnchors(document, header.Schema);
Comment on lines +351 to +354
foreach (var mediaType in content.Values.Where(m => m is not null))
{
if (mediaType.Schema is not null)
RegisterAnchors(document, mediaType.Schema);
Comment on lines +581 to +588
internal OpenApiDocument? FindDocumentByBaseUri(string documentUri)
{
return _dynamicAnchorRegistryByDocument.Keys
.Concat(_anchorRegistryByDocument.Keys)
.Distinct()
.FirstOrDefault(doc => doc.BaseUri is not null
&& doc.BaseUri.ToString().Equals(documentUri, StringComparison.OrdinalIgnoreCase));
}
Comment on lines +181 to +185
/// <summary>
/// Extracts the bare anchor name from a $dynamicRef value.
/// Handles fragment-only (#meta), absolute-URI (https://example.com#meta), and bare (meta) forms.
/// Returns null for null/empty input. Returns empty string for bare "#" (root reference).
/// </summary>
// Act: Serialize using System.Text.Json
var options = new JsonSerializerOptions
{
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve,

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.

I'm curious about why we've had to turn this on?

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.

The sample documents (docWith31properties.json, docWith32properties.json) contain recursive $dynamicRef:

{
  "$dynamicAnchor": "addressDef",
  "properties": {
    "address": { "$dynamicRef": "#addressDef" }
  }
}

After this PR, bare $dynamicRef deserializes as OpenApiSchemaReference. Its convenience getters (Properties, Items, etc.) delegate to Target per the existing Reference.X ?? Target?.X contract. For recursive refs, Target resolves to the ancestor schema, creating a cycle that STJ's default serializer can't handle (max depth 64).

ReferenceHandler.Preserve lets STJ handle cycles via $id/$ref metadata. The test still detects mutations because both snapshots (before/after native serialization) use the same format.

This is a direct consequence of making bare $dynamicRef participate in reference resolution as OpenApiSchemaReference (#2911).

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.

ah thanks for the context! But doesn't that insert $id and $ref that we don't want in the resulting output?

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.

You're right — ReferenceHandler.Preserve adds $id/$ref artifacts that don't belong. Replaced it with a custom JsonConverter<IOpenApiSchema> that routes schema serialization through the native OpenAPI writer, avoiding reflection on the computed getters that create the cycle.

Root cause: the sample documents contain recursive $dynamicRef (e.g. $dynamicAnchor: addressDef with $dynamicRef: #addressDef pointing back to the parent). After this PR, bare $dynamicRef deserializes as OpenApiSchemaReference whose convenience getters delegate to Target, creating a cycle that STJ's default serializer can't handle.

Options considered:

  1. [JsonIgnore] on Target — cycle is through ~45 computed getters, not Target directly
  2. Existing OpenApiSchemaJsonConverter — typed as JsonConverter<OpenApiSchema>, not IOpenApiSchema. Changing the type parameter breaks existing tests because STJ doesn't route concrete types through interface-typed converters at the root level.
  3. MaxDepth = 256 — infinite cycle, no depth limit helps
  4. ReferenceHandler.Preserve — works but pollutes output with $id/$ref
  5. Custom JsonConverter<IOpenApiSchema> — routes schemas through native writer, no artifacts, non-schema properties still compared via STJ reflection

Went with #5. The converter is shared between V31/V32 test files:

internal sealed class OpenApiSchemaInterfaceJsonConverter(
    Action<IOpenApiWriter, IOpenApiSchema> serialize) : JsonConverter<IOpenApiSchema>
{
    public override IOpenApiSchema Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        => throw new NotSupportedException();

    public override void Write(Utf8JsonWriter writer, IOpenApiSchema value, JsonSerializerOptions options)
    {
        using var tw = new StringWriter();
        var ow = new OpenApiJsonWriter(tw);
        serialize(ow, value);
        writer.WriteRawValue(tw.ToString(), skipInputValidation: true);
    }
}

Registered as new OpenApiSchemaInterfaceJsonConverter(static (w, s) => s.SerializeAsV31(w)).

/// When true, serialization emits $dynamicRef instead of $ref, and Target resolution
/// uses the $dynamicAnchor index rather than the $ref URI lookup.
/// </summary>
internal bool IsDynamicRefOnly { get; set; }

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.

The problem I can see with this flag is if someone initializes the reference from the object model (so not deserializing an existing document), it's not being set. So they'll end up with a "broken" document. We need to account for that from the object model constructor/setters. We don't want to make that flag public.
So either the flag need to be calculated from the getter (no field/setter) or the object model needs to set it in that scenario, and we need unit tests to demonstrate that.

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. I replaced the field/setter with a computed getter:

internal bool IsDynamicRefOnly
    => !string.IsNullOrEmpty(DynamicRef)
       && ReferenceV3 is string refV3
       && !refV3.StartsWith("#/components/", StringComparison.OrdinalIgnoreCase);

The deserializer calls SetJsonPointerPath with the raw $dynamicRef value (e.g. #node), which sets ReferenceV3 to a non-component path. So IsDynamicRefOnly is now derived from existing state — no flag to forget.

For object model construction, users need to call SetJsonPointerPath after setting DynamicRef. I updated DynamicRefResolvesInObjectModelBuiltDocument to demonstrate this. Changes are in the latest uncommitted diff — pushing shortly.

- Replace IsDynamicRefOnly field with computed getter based on DynamicRef
  and ReferenceV3 not pointing to #/components/ (per @baywet feedback)
- Add reference-holder guards in anchor registration walk to prevent
  crossing document boundaries through OpenApiParameterReference,
  OpenApiResponseReference, etc. (per Copilot review)
- Lazy-init anchor registry dictionaries to fix EmptyDocument
  performance regression (176 bytes → baseline)
- Use Uri equality instead of ToString().Equals() in FindDocumentByBaseUri
- Tighten ExtractDynamicAnchorName doc comment to describe string
  splitting behavior, not resolution semantics
@aqeelat aqeelat force-pushed the feat/dynamicref-resolution branch from cc6b964 to de6aa5f Compare July 7, 2026 07:22
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