feat(schema): resolve bare $dynamicRef via $dynamicAnchor index#2913
feat(schema): resolve bare $dynamicRef via $dynamicAnchor index#2913aqeelat wants to merge 3 commits into
Conversation
e4c3bd2 to
96e2bcc
Compare
96e2bcc to
573f8bf
Compare
|
@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. |
|
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! |
There was a problem hiding this comment.
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
$dynamicAnchorand$anchordeclarations per document inOpenApiWorkspaceand expose APIs for candidate lookup + context-aware resolution. - Update V31/V32 schema deserializers to create an
OpenApiSchemaReferencefor bare$dynamicRef(no$ref) and preserve siblings viaApplySchemaMetadata. - 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. |
baywet
left a comment
There was a problem hiding this comment.
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.
573f8bf to
06d3b50
Compare
|
@baywet CodeQL is suggesting we convert the |
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. |
06d3b50 to
2ab57de
Compare
|
Done. Converted all All review comments should be addressed now. Do you think this can be merged today? |
|
@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) |
2ab57de to
7a595ff
Compare
7a595ff to
702e385
Compare
…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.
b976475 to
5ea36ad
Compare
…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.
| private void RegisterPathItemAnchors(OpenApiDocument document, IOpenApiPathItem pathItem, HashSet<object> visited) | ||
| { | ||
| if (!visited.Add(pathItem)) return; | ||
|
|
| private void RegisterCallbackAnchors(OpenApiDocument document, IOpenApiCallback callback, HashSet<object> visited) | ||
| { | ||
| if (!visited.Add(callback)) return; | ||
|
|
| private void RegisterRequestBodyAnchors(OpenApiDocument document, IOpenApiRequestBody requestBody) | ||
| => RegisterMediaTypeSchemas(document, requestBody.Content); |
| private void RegisterParameterAnchors(OpenApiDocument document, IOpenApiParameter parameter) | ||
| { | ||
| if (parameter.Schema is not null) | ||
| RegisterAnchors(document, parameter.Schema); |
| private void RegisterResponseAnchors(OpenApiDocument document, IOpenApiResponse response) | ||
| { | ||
| RegisterMediaTypeSchemas(document, response.Content); | ||
| if (response.Headers is not null) |
| private void RegisterHeaderAnchors(OpenApiDocument document, IOpenApiHeader header) | ||
| { | ||
| if (header.Schema is not null) | ||
| RegisterAnchors(document, header.Schema); |
| foreach (var mediaType in content.Values.Where(m => m is not null)) | ||
| { | ||
| if (mediaType.Schema is not null) | ||
| RegisterAnchors(document, mediaType.Schema); |
| 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)); | ||
| } |
| /// <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, |
There was a problem hiding this comment.
I'm curious about why we've had to turn this on?
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
ah thanks for the context! But doesn't that insert $id and $ref that we don't want in the resulting output?
There was a problem hiding this comment.
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:
[JsonIgnore]onTarget— cycle is through ~45 computed getters, notTargetdirectly- Existing
OpenApiSchemaJsonConverter— typed asJsonConverter<OpenApiSchema>, notIOpenApiSchema. Changing the type parameter breaks existing tests because STJ doesn't route concrete types through interface-typed converters at the root level. MaxDepth = 256— infinite cycle, no depth limit helpsReferenceHandler.Preserve— works but pollutes output with$id/$ref- 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; } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
cc6b964 to
de6aa5f
Compare
Pull Request
Description
Implements document-scoped
$dynamicRefresolution per JSON Schema 2020-12 §8.2.3.2. Bare$dynamicRefschemas (no$ref) now deserialize asOpenApiSchemaReferencewhoseTargetresolves via per-document$dynamicAnchorand$anchorregistries inOpenApiWorkspace.Resolution order in
Target$dynamicAnchorindex — single candidate resolves automatically$anchorfallback — when zero$dynamicAnchorcandidates exist (per §8.2.3.2)null— when ambiguous (multiple candidates need dynamic-scope tracking)Type of Change
Public APIs for consumers tracking dynamic scope
GetDynamicAnchorCandidates(doc, anchorName)— returns all candidate schemasResolveDynamicAnchorInContext(contextSchema, anchorName)— resolves against a specific schema's$defs$refsibling behaviorThis PR relies on the existing OpenAPI.NET 3.1/3.2 behavior where
$refschema siblings are preserved onJsonSchemaReferenceand surfaced throughOpenApiSchemaReference's existing reference-first property getters.This PR does not introduce that behavior. It extends the same storage model to bare
$dynamicRefschemas 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,Targetreturnsnull(ambiguous). The spec requires the outermost candidate in the dynamic evaluation scope. Automatic resolution would require threading evaluation context throughTarget(e.g.,AsyncLocal<Stack>). Currently consumers handle this viaGetDynamicAnchorCandidates+ResolveDynamicAnchorInContext. Feedback welcome on whether the library should handle this automatically or leave it to consumers.Testing
Serialize_DoesNotMutateDomfailures on clean main)46 dynamic-ref tests across V31 and V32 covering: bare
$dynamicRefdeserialization, resolution to anchors in all schema locations, ambiguity handling,$anchorfallback,$dynamicAnchorprecedence, round-trip serialization, sibling preservation, context-aware resolution, OM-built document, and$refregression.Microsoft.OpenApi.Tests: 1149 passed.Microsoft.OpenApi.Readers.Tests: 535 passed, 7 pre-existing failures.Checklist
Versions applicability
Related
$refschemas)