Skip to content

Commit 7bcdecb

Browse files
committed
fix: preserve JSON Schema 2020-12 keyword siblings on $ref schemas for OAS 3.1+
OpenApiV31Deserializer.LoadSchema short-circuits on $ref before ParseMap, so sibling keywords ($defs, $dynamicAnchor, $dynamicRef, $id, $anchor, $vocabulary, $comment) were never parsed into the object model. This made Pattern B (generic template + binding) unimplementable for any tool built on Microsoft.OpenApi. The fix mirrors the #2369 annotation-sibling pattern across four coordinated changes: - Parser extraction in SetAdditional31MetadataFromMapNode (scalars + $vocabulary) and LoadSchema ($defs, which needs LoadSchema for nested schema materialization) - Storage: 7 new properties on JsonSchemaReference - Accessor overrides on OpenApiSchemaReference (Reference.X ?? Target?.X) - Serialization in SerializeAdditionalV3XProperties Version-safe by call-site separation: SetAdditional31MetadataFromMapNode is only reachable from V31/V32 LoadSchema, never V3. Ref: #2895
1 parent 7345caa commit 7bcdecb

6 files changed

Lines changed: 291 additions & 10 deletions

File tree

src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,42 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
5858
/// </summary>
5959
public IDictionary<string, IOpenApiExtension>? Extensions { get; set; }
6060

61+
/// <summary>
62+
/// A $id which by default SHOULD override that of the referenced component.
63+
/// Named SchemaId to avoid collision with the inherited reference identifier (BaseOpenApiReference.Id).
64+
/// </summary>
65+
public string? SchemaId { get; set; }
66+
67+
/// <summary>
68+
/// A $comment which by default SHOULD override that of the referenced component.
69+
/// </summary>
70+
public string? Comment { get; set; }
71+
72+
/// <summary>
73+
/// The $vocabulary which by default SHOULD override that of the referenced component.
74+
/// </summary>
75+
public IDictionary<string, bool>? Vocabulary { get; set; }
76+
77+
/// <summary>
78+
/// The $dynamicRef which by default SHOULD override that of the referenced component.
79+
/// </summary>
80+
public string? DynamicRef { get; set; }
81+
82+
/// <summary>
83+
/// The $dynamicAnchor which by default SHOULD override that of the referenced component.
84+
/// </summary>
85+
public string? DynamicAnchor { get; set; }
86+
87+
/// <summary>
88+
/// The $defs which by default SHOULD override that of the referenced component.
89+
/// </summary>
90+
public IDictionary<string, IOpenApiSchema>? Definitions { get; set; }
91+
92+
/// <summary>
93+
/// The $anchor which by default SHOULD override that of the referenced component.
94+
/// </summary>
95+
public string? Anchor { get; set; }
96+
6197
/// <summary>
6298
/// Parameterless constructor
6399
/// </summary>
@@ -76,24 +112,48 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference)
76112
WriteOnly = reference.WriteOnly;
77113
Examples = reference.Examples;
78114
Extensions = reference.Extensions != null ? new Dictionary<string, IOpenApiExtension>(reference.Extensions) : null;
115+
SchemaId = reference.SchemaId;
116+
Comment = reference.Comment;
117+
Vocabulary = reference.Vocabulary != null ? new Dictionary<string, bool>(reference.Vocabulary) : null;
118+
DynamicRef = reference.DynamicRef;
119+
DynamicAnchor = reference.DynamicAnchor;
120+
Definitions = reference.Definitions != null ? new Dictionary<string, IOpenApiSchema>(reference.Definitions) : null;
121+
Anchor = reference.Anchor;
79122
}
80123

81124
/// <inheritdoc/>
82125
protected override void SerializeAdditionalV31Properties(IOpenApiWriter writer)
83126
{
84-
SerializeAdditionalV3XProperties(writer, base.SerializeAdditionalV31Properties);
127+
SerializeAdditionalV3XProperties(writer, OpenApiSpecVersion.OpenApi3_1, base.SerializeAdditionalV31Properties);
85128
}
86129
/// <inheritdoc/>
87130
protected override void SerializeAdditionalV32Properties(IOpenApiWriter writer)
88131
{
89-
SerializeAdditionalV3XProperties(writer, base.SerializeAdditionalV32Properties);
132+
SerializeAdditionalV3XProperties(writer, OpenApiSpecVersion.OpenApi3_2, base.SerializeAdditionalV32Properties);
90133
}
91-
private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action<IOpenApiWriter> baseSerializer)
134+
private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, OpenApiSpecVersion version, Action<IOpenApiWriter> baseSerializer)
92135
{
93136
if (Type != ReferenceType.Schema) throw new InvalidOperationException(
94137
$"JsonSchemaReference can only be serialized for ReferenceType.Schema, but was {Type}.");
95138

96139
baseSerializer(writer);
140+
141+
// JSON Schema 2020-12 keyword siblings (preserved per OAS 3.1+ / JSON Schema 2020-12 semantics)
142+
writer.WriteProperty(OpenApiConstants.Id, SchemaId);
143+
writer.WriteProperty(OpenApiConstants.Comment, Comment);
144+
writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s));
145+
if (version == OpenApiSpecVersion.OpenApi3_1)
146+
{
147+
writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV31(w));
148+
}
149+
else
150+
{
151+
writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV32(w));
152+
}
153+
writer.WriteProperty(OpenApiConstants.Anchor, Anchor);
154+
writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef);
155+
writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor);
156+
97157
// Additional schema metadata annotations in 3.1
98158
writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d));
99159
writer.WriteProperty(OpenApiConstants.Title, Title);
@@ -164,5 +224,25 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject
164224
Extensions ??= new Dictionary<string, IOpenApiExtension>(StringComparer.OrdinalIgnoreCase);
165225
Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone());
166226
}
227+
228+
// JSON Schema 2020-12 keyword siblings ($defs is parsed separately in the deserializer
229+
// because it requires LoadSchema for nested schema materialization)
230+
SchemaId = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Id) ?? SchemaId;
231+
Comment = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Comment) ?? Comment;
232+
DynamicRef = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicRef) ?? DynamicRef;
233+
DynamicAnchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicAnchor) ?? DynamicAnchor;
234+
Anchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Anchor) ?? Anchor;
235+
236+
if (jsonObject.TryGetPropertyValue(OpenApiConstants.Vocabulary, out var vocabNode) && vocabNode is JsonObject vocabObj)
237+
{
238+
Vocabulary = new Dictionary<string, bool>();
239+
foreach (var kvp in vocabObj)
240+
{
241+
if (kvp.Value is JsonValue v && v.TryGetValue<bool>(out var b))
242+
{
243+
Vocabulary[kvp.Key] = b;
244+
}
245+
}
246+
}
167247
}
168248
}

src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,19 @@ public string? Title
5151
/// <inheritdoc/>
5252
public Uri? Schema { get => Target?.Schema; }
5353
/// <inheritdoc/>
54-
public string? Id { get => Target?.Id; }
54+
public string? Id { get => string.IsNullOrEmpty(Reference.SchemaId) ? Target?.Id : Reference.SchemaId; }
5555
/// <inheritdoc/>
56-
public string? Comment { get => Target?.Comment; }
56+
public string? Comment { get => string.IsNullOrEmpty(Reference.Comment) ? Target?.Comment : Reference.Comment; }
5757
/// <inheritdoc/>
58-
public IDictionary<string, bool>? Vocabulary { get => Target?.Vocabulary; }
58+
public IDictionary<string, bool>? Vocabulary { get => Reference.Vocabulary ?? Target?.Vocabulary; }
5959
/// <inheritdoc/>
60-
public string? DynamicRef { get => Target?.DynamicRef; }
60+
public string? DynamicRef { get => string.IsNullOrEmpty(Reference.DynamicRef) ? Target?.DynamicRef : Reference.DynamicRef; }
6161
/// <inheritdoc/>
62-
public string? DynamicAnchor { get => Target?.DynamicAnchor; }
62+
public string? DynamicAnchor { get => string.IsNullOrEmpty(Reference.DynamicAnchor) ? Target?.DynamicAnchor : Reference.DynamicAnchor; }
6363
/// <inheritdoc/>
64-
public IDictionary<string, IOpenApiSchema>? Definitions { get => Target?.Definitions; }
64+
public IDictionary<string, IOpenApiSchema>? Definitions { get => Reference.Definitions ?? Target?.Definitions; }
6565
/// <inheritdoc/>
66-
public string? Anchor { get => (Target as IOpenApiSchemaMissingProperties)?.Anchor; }
66+
public string? Anchor { get => string.IsNullOrEmpty(Reference.Anchor) ? (Target as IOpenApiSchemaMissingProperties)?.Anchor : Reference.Anchor; }
6767
/// <inheritdoc/>
6868
public string? ExclusiveMaximum { get => Target?.ExclusiveMaximum; }
6969
/// <inheritdoc/>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
11
#nullable enable
2+
Microsoft.OpenApi.JsonSchemaReference.Anchor.get -> string?
3+
Microsoft.OpenApi.JsonSchemaReference.Anchor.set -> void
4+
Microsoft.OpenApi.JsonSchemaReference.Comment.get -> string?
5+
Microsoft.OpenApi.JsonSchemaReference.Comment.set -> void
6+
Microsoft.OpenApi.JsonSchemaReference.Definitions.get -> System.Collections.Generic.IDictionary<string!, Microsoft.OpenApi.IOpenApiSchema!>?
7+
Microsoft.OpenApi.JsonSchemaReference.Definitions.set -> void
8+
Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.get -> string?
9+
Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.set -> void
10+
Microsoft.OpenApi.JsonSchemaReference.DynamicRef.get -> string?
11+
Microsoft.OpenApi.JsonSchemaReference.DynamicRef.set -> void
12+
Microsoft.OpenApi.JsonSchemaReference.SchemaId.get -> string?
13+
Microsoft.OpenApi.JsonSchemaReference.SchemaId.set -> void
14+
Microsoft.OpenApi.JsonSchemaReference.Vocabulary.get -> System.Collections.Generic.IDictionary<string!, bool>?
15+
Microsoft.OpenApi.JsonSchemaReference.Vocabulary.set -> void

src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,22 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
452452
var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
453453
result.Reference.SetMetadataFromJsonObject(jsonObject);
454454
result.Reference.SetJsonPointerPath(pointer, nodeLocation);
455+
456+
// Parse $defs sibling — requires LoadSchema for nested schema materialization,
457+
// so it cannot be done inside SetAdditional31MetadataFromMapNode.
458+
if (jsonObject.TryGetPropertyValue(OpenApiConstants.Defs, out var defsNode) && defsNode is JsonObject defsObj)
459+
{
460+
var defs = new Dictionary<string, IOpenApiSchema>(StringComparer.Ordinal);
461+
foreach (var kvp in defsObj)
462+
{
463+
if (kvp.Value is not null)
464+
{
465+
defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context);
466+
}
467+
}
468+
result.Reference.Definitions = defs;
469+
}
470+
455471
return result;
456472
}
457473

src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,22 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum
452452
var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2);
453453
result.Reference.SetMetadataFromJsonObject(jsonObject);
454454
result.Reference.SetJsonPointerPath(pointer, nodeLocation);
455+
456+
// Parse $defs sibling — requires LoadSchema for nested schema materialization,
457+
// so it cannot be done inside SetAdditional31MetadataFromMapNode.
458+
if (jsonObject.TryGetPropertyValue(OpenApiConstants.Defs, out var defsNode) && defsNode is JsonObject defsObj)
459+
{
460+
var defs = new Dictionary<string, IOpenApiSchema>(StringComparer.Ordinal);
461+
foreach (var kvp in defsObj)
462+
{
463+
if (kvp.Value is not null)
464+
{
465+
defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context);
466+
}
467+
}
468+
result.Reference.Definitions = defs;
469+
}
470+
455471
return result;
456472
}
457473

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

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,5 +935,160 @@ public void DeserializeFalseSchemaParsesAsNotEmptySchema()
935935
Assert.Empty(schema.Not.AllOf ?? []);
936936
Assert.Empty(schema.Not.OneOf ?? []);
937937
}
938+
939+
[Fact]
940+
public async Task ParseSchemaReferencePreservesJsonSchema2020KeywordSiblings()
941+
{
942+
// Arrange
943+
var yaml = """
944+
openapi: 3.1.0
945+
info:
946+
title: Sibling preservation repro
947+
version: 1.0.0
948+
paths: {}
949+
components:
950+
schemas:
951+
Target:
952+
type: object
953+
properties:
954+
name:
955+
type: string
956+
Referencing:
957+
$ref: '#/components/schemas/Target'
958+
description: Sibling description
959+
$dynamicAnchor: anchor
960+
$defs:
961+
sibling:
962+
$dynamicAnchor: inner
963+
$ref: '#/components/schemas/Target'
964+
""";
965+
966+
// Act
967+
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml));
968+
var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings);
969+
var referencing = result.Document.Components!.Schemas["Referencing"];
970+
971+
// Assert — siblings are preserved on the OpenApiSchemaReference
972+
referencing.Should().BeOfType<OpenApiSchemaReference>();
973+
referencing.Description.Should().Be("Sibling description");
974+
referencing.DynamicAnchor.Should().Be("anchor");
975+
referencing.Definitions.Should().NotBeNull();
976+
referencing.Definitions!.Should().ContainKey("sibling");
977+
referencing.Definitions["sibling"].DynamicAnchor.Should().Be("inner");
978+
}
979+
980+
[Fact]
981+
public async Task SerializeSchemaReferencePreservesJsonSchema2020KeywordSiblings()
982+
{
983+
// Arrange
984+
var yaml = """
985+
openapi: 3.1.0
986+
info:
987+
title: Sibling preservation repro
988+
version: 1.0.0
989+
paths: {}
990+
components:
991+
schemas:
992+
Target:
993+
type: object
994+
properties:
995+
name:
996+
type: string
997+
Referencing:
998+
$ref: '#/components/schemas/Target'
999+
$dynamicAnchor: anchor
1000+
$defs:
1001+
itemType:
1002+
$dynamicAnchor: itemType
1003+
$ref: '#/components/schemas/Target'
1004+
""";
1005+
1006+
// Act — parse then serialize back
1007+
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml));
1008+
var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings);
1009+
var writer = new StringWriter();
1010+
result.Document.SerializeAsV31(new OpenApiYamlWriter(writer));
1011+
var output = writer.ToString();
1012+
1013+
// Assert — round-trip preserves $dynamicAnchor and $defs alongside $ref
1014+
using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output));
1015+
var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings);
1016+
var referencing = roundTripResult.Document.Components!.Schemas["Referencing"];
1017+
1018+
referencing.Should().BeOfType<OpenApiSchemaReference>();
1019+
referencing.DynamicAnchor.Should().Be("anchor");
1020+
referencing.Definitions.Should().NotBeNull();
1021+
referencing.Definitions!.Should().ContainKey("itemType");
1022+
referencing.Definitions["itemType"].DynamicAnchor.Should().Be("itemType");
1023+
}
1024+
1025+
[Fact]
1026+
public async Task ParseSchemaReferencePreservesScalarKeywordSiblings()
1027+
{
1028+
// Arrange
1029+
var yaml = """
1030+
openapi: 3.1.0
1031+
info:
1032+
title: Scalar sibling repro
1033+
version: 1.0.0
1034+
paths: {}
1035+
components:
1036+
schemas:
1037+
Target:
1038+
type: object
1039+
Referencing:
1040+
$ref: '#/components/schemas/Target'
1041+
$id: 'https://example.com/referencing.json'
1042+
$comment: A comment sibling
1043+
$anchor: myAnchor
1044+
$dynamicRef: '#myAnchor'
1045+
""";
1046+
1047+
// Act
1048+
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml));
1049+
var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings);
1050+
var referencing = result.Document.Components!.Schemas["Referencing"];
1051+
1052+
// Assert
1053+
referencing.Should().BeOfType<OpenApiSchemaReference>();
1054+
referencing.Id.Should().Be("https://example.com/referencing.json");
1055+
referencing.Comment.Should().Be("A comment sibling");
1056+
((IOpenApiSchemaMissingProperties)referencing).Anchor.Should().Be("myAnchor");
1057+
referencing.DynamicRef.Should().Be("#myAnchor");
1058+
}
1059+
1060+
[Fact]
1061+
public async Task ParseSchemaReferencePreservesVocabularySibling()
1062+
{
1063+
// Arrange
1064+
var yaml = """
1065+
openapi: 3.1.0
1066+
info:
1067+
title: Vocabulary sibling repro
1068+
version: 1.0.0
1069+
paths: {}
1070+
components:
1071+
schemas:
1072+
Target:
1073+
type: object
1074+
Referencing:
1075+
$ref: '#/components/schemas/Target'
1076+
$vocabulary:
1077+
'https://json-schema.org/draft/2020-12/vocab/core': true
1078+
'https://json-schema.org/draft/2020-12/vocab/applicator': false
1079+
""";
1080+
1081+
// Act
1082+
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml));
1083+
var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings);
1084+
var referencing = result.Document.Components!.Schemas["Referencing"];
1085+
1086+
// Assert
1087+
referencing.Should().BeOfType<OpenApiSchemaReference>();
1088+
referencing.Vocabulary.Should().NotBeNull();
1089+
referencing.Vocabulary!.Should().HaveCount(2);
1090+
referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"].Should().BeTrue();
1091+
referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"].Should().BeFalse();
1092+
}
9381093
}
9391094
}

0 commit comments

Comments
 (0)