Skip to content

Commit b35d82b

Browse files
committed
fix(workspace): only consider authored $dynamicAnchor in ResolveDynamicAnchorInContext
$defs entries that are themselves $refs no longer fall through to the referenced target's $dynamicAnchor. OpenApiSchemaReference.DynamicAnchor delegates to Target when the authored sibling is empty, which violated the method's context-bound intent. Adds AuthoredDynamicAnchor helper that reads Reference.DynamicAnchor for OpenApiSchemaReference and the plain property otherwise. Both $defs lookups (non-reference context branch and reference-holder branch) now use it. Tests cover both branches in V31 and V32: a Container schema with an aliased $ref def whose Target declares $dynamicAnchor: node, plus a reference holder whose Reference.Definitions carries the same aliased entry.
1 parent 6e3e271 commit b35d82b

3 files changed

Lines changed: 120 additions & 2 deletions

File tree

src/Microsoft.OpenApi/Services/OpenApiWorkspace.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -750,16 +750,23 @@ public IReadOnlyList<IOpenApiSchema> GetDynamicAnchorCandidates(OpenApiDocument
750750
if (osr.Reference.DynamicAnchor is string a && a.Equals(anchorName, StringComparison.Ordinal))
751751
return contextSchema;
752752
return osr.Reference.Definitions?.Values.FirstOrDefault(def =>
753-
def.DynamicAnchor is string da && da.Equals(anchorName, StringComparison.Ordinal));
753+
AuthoredDynamicAnchor(def) is string da && da.Equals(anchorName, StringComparison.Ordinal));
754754
}
755755

756756
if (contextSchema.DynamicAnchor is string b && b.Equals(anchorName, StringComparison.Ordinal))
757757
return contextSchema;
758758

759759
return contextSchema.Definitions?.Values.FirstOrDefault(def =>
760-
def.DynamicAnchor is string da && da.Equals(anchorName, StringComparison.Ordinal));
760+
AuthoredDynamicAnchor(def) is string da && da.Equals(anchorName, StringComparison.Ordinal));
761761
}
762762

763+
// Reads only the authored $dynamicAnchor on the schema itself. For OpenApiSchemaReference,
764+
// IOpenApiSchema.DynamicAnchor falls through to Target when the reference's authored sibling
765+
// is empty, which would resolve an anchor declared on the referenced target rather than on
766+
// the $defs entry. Context-bound resolution must consider only the authored sibling.
767+
private static string? AuthoredDynamicAnchor(IOpenApiSchema schema)
768+
=> schema is OpenApiSchemaReference r ? r.Reference.DynamicAnchor : schema.DynamicAnchor;
769+
763770
/// <summary>
764771
/// Adds a document id to the dictionaries of document locations and their ids.
765772
/// </summary>

test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDynamicRefTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,61 @@ public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReference
11071107
Assert.Null(result);
11081108
}
11091109

1110+
[Fact]
1111+
public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReferenceDefsEntries()
1112+
{
1113+
// A $defs entry that is itself a $ref must contribute its own authored $dynamicAnchor,
1114+
// not an anchor declared on the referenced target. The "aliased" def points at a target
1115+
// that declares $dynamicAnchor: node, but the def itself does not author it, so the
1116+
// context-bound lookup must return null.
1117+
var yaml =
1118+
"""
1119+
openapi: 3.1.0
1120+
info:
1121+
title: Test
1122+
version: 1.0.0
1123+
paths: {}
1124+
components:
1125+
schemas:
1126+
Target:
1127+
$dynamicAnchor: node
1128+
type: object
1129+
properties:
1130+
name:
1131+
type: string
1132+
Container:
1133+
type: object
1134+
$defs:
1135+
aliased:
1136+
$ref: '#/components/schemas/Target'
1137+
""";
1138+
1139+
var doc = await LoadDocumentAsync(yaml);
1140+
1141+
var container = doc.Components.Schemas["Container"];
1142+
var aliased = Assert.IsType<OpenApiSchemaReference>(container.Definitions!["aliased"]);
1143+
// Sanity: the fall-through property would surface the target's anchor...
1144+
Assert.Equal("node", aliased.DynamicAnchor);
1145+
// ...but the authored sibling on the def entry is empty.
1146+
Assert.Null(aliased.Reference.DynamicAnchor);
1147+
1148+
// Both the non-reference context branch (Container) and the reference-holder branch
1149+
// (a ref whose Reference.Definitions carries the entry) must ignore the target's anchor.
1150+
Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(container, "node"));
1151+
1152+
var refWithDefs = new OpenApiSchemaReference("Container", doc)
1153+
{
1154+
Reference =
1155+
{
1156+
Definitions = new Dictionary<string, IOpenApiSchema>
1157+
{
1158+
["aliased"] = aliased
1159+
}
1160+
}
1161+
};
1162+
Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(refWithDefs, "node"));
1163+
}
1164+
11101165
[Fact]
11111166
public async Task DynamicRefFallsBackToPlainAnchor()
11121167
{

test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiDynamicRefTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Generic;
12
using System.IO;
23
using System.Text;
34
using System.Text.Json.Nodes;
@@ -944,4 +945,59 @@ public async Task InliningDynamicRefProducesResolvedTargetSchema()
944945
Assert.NotNull(inlined.Properties);
945946
Assert.True(inlined.Properties.ContainsKey("value"));
946947
}
948+
949+
[Fact]
950+
public async Task ResolveDynamicAnchorInContextDoesNotReadFromTargetForReferenceDefsEntries()
951+
{
952+
// A $defs entry that is itself a $ref must contribute its own authored $dynamicAnchor,
953+
// not an anchor declared on the referenced target. The "aliased" def points at a target
954+
// that declares $dynamicAnchor: node, but the def itself does not author it, so the
955+
// context-bound lookup must return null.
956+
var yaml =
957+
"""
958+
openapi: 3.2.0
959+
info:
960+
title: Test
961+
version: 1.0.0
962+
paths: {}
963+
components:
964+
schemas:
965+
Target:
966+
$dynamicAnchor: node
967+
type: object
968+
properties:
969+
name:
970+
type: string
971+
Container:
972+
type: object
973+
$defs:
974+
aliased:
975+
$ref: '#/components/schemas/Target'
976+
""";
977+
978+
var doc = await LoadDocumentAsync(yaml);
979+
980+
var container = doc.Components.Schemas["Container"];
981+
var aliased = Assert.IsType<OpenApiSchemaReference>(container.Definitions!["aliased"]);
982+
// Sanity: the fall-through property would surface the target's anchor...
983+
Assert.Equal("node", aliased.DynamicAnchor);
984+
// ...but the authored sibling on the def entry is empty.
985+
Assert.Null(aliased.Reference.DynamicAnchor);
986+
987+
// Both the non-reference context branch (Container) and the reference-holder branch
988+
// (a ref whose Reference.Definitions carries the entry) must ignore the target's anchor.
989+
Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(container, "node"));
990+
991+
var refWithDefs = new OpenApiSchemaReference("Container", doc)
992+
{
993+
Reference =
994+
{
995+
Definitions = new Dictionary<string, IOpenApiSchema>
996+
{
997+
["aliased"] = aliased
998+
}
999+
}
1000+
};
1001+
Assert.Null(OpenApiWorkspace.ResolveDynamicAnchorInContext(refWithDefs, "node"));
1002+
}
9471003
}

0 commit comments

Comments
 (0)