From 7bcdecb38a51db8e256ebc7e54e0330503f3d2fc Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 19 Jun 2026 18:33:12 +0300 Subject: [PATCH 1/7] 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: microsoft/OpenAPI.NET#2895 --- .../Models/JsonSchemaReference.cs | 86 +++++++++- .../References/OpenApiSchemaReference.cs | 14 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 14 ++ .../Reader/V31/OpenApiSchemaDeserializer.cs | 16 ++ .../Reader/V32/OpenApiSchemaDeserializer.cs | 16 ++ .../V31Tests/OpenApiSchemaTests.cs | 155 ++++++++++++++++++ 6 files changed, 291 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs index fff77e4cc..0b76cd3be 100644 --- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs @@ -58,6 +58,42 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription /// public IDictionary? Extensions { get; set; } + /// + /// A $id which by default SHOULD override that of the referenced component. + /// Named SchemaId to avoid collision with the inherited reference identifier (BaseOpenApiReference.Id). + /// + public string? SchemaId { get; set; } + + /// + /// A $comment which by default SHOULD override that of the referenced component. + /// + public string? Comment { get; set; } + + /// + /// The $vocabulary which by default SHOULD override that of the referenced component. + /// + public IDictionary? Vocabulary { get; set; } + + /// + /// The $dynamicRef which by default SHOULD override that of the referenced component. + /// + public string? DynamicRef { get; set; } + + /// + /// The $dynamicAnchor which by default SHOULD override that of the referenced component. + /// + public string? DynamicAnchor { get; set; } + + /// + /// The $defs which by default SHOULD override that of the referenced component. + /// + public IDictionary? Definitions { get; set; } + + /// + /// The $anchor which by default SHOULD override that of the referenced component. + /// + public string? Anchor { get; set; } + /// /// Parameterless constructor /// @@ -76,24 +112,48 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference) WriteOnly = reference.WriteOnly; Examples = reference.Examples; Extensions = reference.Extensions != null ? new Dictionary(reference.Extensions) : null; + SchemaId = reference.SchemaId; + Comment = reference.Comment; + Vocabulary = reference.Vocabulary != null ? new Dictionary(reference.Vocabulary) : null; + DynamicRef = reference.DynamicRef; + DynamicAnchor = reference.DynamicAnchor; + Definitions = reference.Definitions != null ? new Dictionary(reference.Definitions) : null; + Anchor = reference.Anchor; } /// protected override void SerializeAdditionalV31Properties(IOpenApiWriter writer) { - SerializeAdditionalV3XProperties(writer, base.SerializeAdditionalV31Properties); + SerializeAdditionalV3XProperties(writer, OpenApiSpecVersion.OpenApi3_1, base.SerializeAdditionalV31Properties); } /// protected override void SerializeAdditionalV32Properties(IOpenApiWriter writer) { - SerializeAdditionalV3XProperties(writer, base.SerializeAdditionalV32Properties); + SerializeAdditionalV3XProperties(writer, OpenApiSpecVersion.OpenApi3_2, base.SerializeAdditionalV32Properties); } - private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action baseSerializer) + private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, OpenApiSpecVersion version, Action baseSerializer) { if (Type != ReferenceType.Schema) throw new InvalidOperationException( $"JsonSchemaReference can only be serialized for ReferenceType.Schema, but was {Type}."); baseSerializer(writer); + + // JSON Schema 2020-12 keyword siblings (preserved per OAS 3.1+ / JSON Schema 2020-12 semantics) + writer.WriteProperty(OpenApiConstants.Id, SchemaId); + writer.WriteProperty(OpenApiConstants.Comment, Comment); + writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); + if (version == OpenApiSpecVersion.OpenApi3_1) + { + writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV31(w)); + } + else + { + writer.WriteOptionalMap(OpenApiConstants.Defs, Definitions, (w, s) => s.SerializeAsV32(w)); + } + writer.WriteProperty(OpenApiConstants.Anchor, Anchor); + writer.WriteProperty(OpenApiConstants.DynamicRef, DynamicRef); + writer.WriteProperty(OpenApiConstants.DynamicAnchor, DynamicAnchor); + // Additional schema metadata annotations in 3.1 writer.WriteOptionalObject(OpenApiConstants.Default, Default, (w, d) => w.WriteAny(d)); writer.WriteProperty(OpenApiConstants.Title, Title); @@ -164,5 +224,25 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject Extensions ??= new Dictionary(StringComparer.OrdinalIgnoreCase); Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone()); } + + // JSON Schema 2020-12 keyword siblings ($defs is parsed separately in the deserializer + // because it requires LoadSchema for nested schema materialization) + SchemaId = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Id) ?? SchemaId; + Comment = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Comment) ?? Comment; + DynamicRef = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicRef) ?? DynamicRef; + DynamicAnchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicAnchor) ?? DynamicAnchor; + Anchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Anchor) ?? Anchor; + + if (jsonObject.TryGetPropertyValue(OpenApiConstants.Vocabulary, out var vocabNode) && vocabNode is JsonObject vocabObj) + { + Vocabulary = new Dictionary(); + foreach (var kvp in vocabObj) + { + if (kvp.Value is JsonValue v && v.TryGetValue(out var b)) + { + Vocabulary[kvp.Key] = b; + } + } + } } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 8601e1bd8..eee73b86e 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -51,19 +51,19 @@ public string? Title /// public Uri? Schema { get => Target?.Schema; } /// - public string? Id { get => Target?.Id; } + public string? Id { get => string.IsNullOrEmpty(Reference.SchemaId) ? Target?.Id : Reference.SchemaId; } /// - public string? Comment { get => Target?.Comment; } + public string? Comment { get => string.IsNullOrEmpty(Reference.Comment) ? Target?.Comment : Reference.Comment; } /// - public IDictionary? Vocabulary { get => Target?.Vocabulary; } + public IDictionary? Vocabulary { get => Reference.Vocabulary ?? Target?.Vocabulary; } /// - public string? DynamicRef { get => Target?.DynamicRef; } + public string? DynamicRef { get => string.IsNullOrEmpty(Reference.DynamicRef) ? Target?.DynamicRef : Reference.DynamicRef; } /// - public string? DynamicAnchor { get => Target?.DynamicAnchor; } + public string? DynamicAnchor { get => string.IsNullOrEmpty(Reference.DynamicAnchor) ? Target?.DynamicAnchor : Reference.DynamicAnchor; } /// - public IDictionary? Definitions { get => Target?.Definitions; } + public IDictionary? Definitions { get => Reference.Definitions ?? Target?.Definitions; } /// - public string? Anchor { get => (Target as IOpenApiSchemaMissingProperties)?.Anchor; } + public string? Anchor { get => string.IsNullOrEmpty(Reference.Anchor) ? (Target as IOpenApiSchemaMissingProperties)?.Anchor : Reference.Anchor; } /// public string? ExclusiveMaximum { get => Target?.ExclusiveMaximum; } /// diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..be46d5e0d 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,15 @@ #nullable enable +Microsoft.OpenApi.JsonSchemaReference.Anchor.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Anchor.set -> void +Microsoft.OpenApi.JsonSchemaReference.Comment.get -> string? +Microsoft.OpenApi.JsonSchemaReference.Comment.set -> void +Microsoft.OpenApi.JsonSchemaReference.Definitions.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.Definitions.set -> void +Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.get -> string? +Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.set -> void +Microsoft.OpenApi.JsonSchemaReference.DynamicRef.get -> string? +Microsoft.OpenApi.JsonSchemaReference.DynamicRef.set -> void +Microsoft.OpenApi.JsonSchemaReference.SchemaId.get -> string? +Microsoft.OpenApi.JsonSchemaReference.SchemaId.set -> void +Microsoft.OpenApi.JsonSchemaReference.Vocabulary.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.Vocabulary.set -> void diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 962ad5283..22051a6a3 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -452,6 +452,22 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); result.Reference.SetMetadataFromJsonObject(jsonObject); result.Reference.SetJsonPointerPath(pointer, nodeLocation); + + // Parse $defs sibling — requires LoadSchema for nested schema materialization, + // so it cannot be done inside SetAdditional31MetadataFromMapNode. + if (jsonObject.TryGetPropertyValue(OpenApiConstants.Defs, out var defsNode) && defsNode is JsonObject defsObj) + { + var defs = new Dictionary(StringComparer.Ordinal); + foreach (var kvp in defsObj) + { + if (kvp.Value is not null) + { + defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context); + } + } + result.Reference.Definitions = defs; + } + return result; } diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index 9d35aaf5a..661f750ac 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -452,6 +452,22 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var result = new OpenApiSchemaReference(reference.Item1, hostDocument, reference.Item2); result.Reference.SetMetadataFromJsonObject(jsonObject); result.Reference.SetJsonPointerPath(pointer, nodeLocation); + + // Parse $defs sibling — requires LoadSchema for nested schema materialization, + // so it cannot be done inside SetAdditional31MetadataFromMapNode. + if (jsonObject.TryGetPropertyValue(OpenApiConstants.Defs, out var defsNode) && defsNode is JsonObject defsObj) + { + var defs = new Dictionary(StringComparer.Ordinal); + foreach (var kvp in defsObj) + { + if (kvp.Value is not null) + { + defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context); + } + } + result.Reference.Definitions = defs; + } + return result; } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index db7488904..aa4377244 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -935,5 +935,160 @@ public void DeserializeFalseSchemaParsesAsNotEmptySchema() Assert.Empty(schema.Not.AllOf ?? []); Assert.Empty(schema.Not.OneOf ?? []); } + + [Fact] + public async Task ParseSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + description: Sibling description + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert — siblings are preserved on the OpenApiSchemaReference + referencing.Should().BeOfType(); + referencing.Description.Should().Be("Sibling description"); + referencing.DynamicAnchor.Should().Be("anchor"); + referencing.Definitions.Should().NotBeNull(); + referencing.Definitions!.Should().ContainKey("sibling"); + referencing.Definitions["sibling"].DynamicAnchor.Should().Be("inner"); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var writer = new StringWriter(); + result.Document.SerializeAsV31(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves $dynamicAnchor and $defs alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings); + var referencing = roundTripResult.Document.Components!.Schemas["Referencing"]; + + referencing.Should().BeOfType(); + referencing.DynamicAnchor.Should().Be("anchor"); + referencing.Definitions.Should().NotBeNull(); + referencing.Definitions!.Should().ContainKey("itemType"); + referencing.Definitions["itemType"].DynamicAnchor.Should().Be("itemType"); + } + + [Fact] + public async Task ParseSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Scalar sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert + referencing.Should().BeOfType(); + referencing.Id.Should().Be("https://example.com/referencing.json"); + referencing.Comment.Should().Be("A comment sibling"); + ((IOpenApiSchemaMissingProperties)referencing).Anchor.Should().Be("myAnchor"); + referencing.DynamicRef.Should().Be("#myAnchor"); + } + + [Fact] + public async Task ParseSchemaReferencePreservesVocabularySibling() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Vocabulary sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + 'https://json-schema.org/draft/2020-12/vocab/applicator': false + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert + referencing.Should().BeOfType(); + referencing.Vocabulary.Should().NotBeNull(); + referencing.Vocabulary!.Should().HaveCount(2); + referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"].Should().BeTrue(); + referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"].Should().BeFalse(); + } } } From ef3a1a623e9e31c950d52cdc909c7981efcbaee5 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 19 Jun 2026 18:36:38 +0300 Subject: [PATCH 2/7] fix: align scalar extraction pattern with existing Title convention Switch from GetPropertyValueFromNode(...) ?? X to the if (!string.IsNullOrEmpty(...)) pattern used by the existing Title/annotation extraction, for reviewer consistency. Also add test for the allOf-based binding variant where $defs sits inside allOf[0] and the nested schema has $ref + $dynamicAnchor (the pattern from the blocker analysis). Ref: microsoft/OpenAPI.NET#2895 --- .../Models/JsonSchemaReference.cs | 34 ++++++++++-- .../V31Tests/OpenApiSchemaTests.cs | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs index 0b76cd3be..91a688e37 100644 --- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs @@ -227,11 +227,35 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject // JSON Schema 2020-12 keyword siblings ($defs is parsed separately in the deserializer // because it requires LoadSchema for nested schema materialization) - SchemaId = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Id) ?? SchemaId; - Comment = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Comment) ?? Comment; - DynamicRef = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicRef) ?? DynamicRef; - DynamicAnchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicAnchor) ?? DynamicAnchor; - Anchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Anchor) ?? Anchor; + var id = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Id); + if (!string.IsNullOrEmpty(id)) + { + SchemaId = id; + } + + var comment = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Comment); + if (!string.IsNullOrEmpty(comment)) + { + Comment = comment; + } + + var dynamicRef = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicRef); + if (!string.IsNullOrEmpty(dynamicRef)) + { + DynamicRef = dynamicRef; + } + + var dynamicAnchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DynamicAnchor); + if (!string.IsNullOrEmpty(dynamicAnchor)) + { + DynamicAnchor = dynamicAnchor; + } + + var anchor = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Anchor); + if (!string.IsNullOrEmpty(anchor)) + { + Anchor = anchor; + } if (jsonObject.TryGetPropertyValue(OpenApiConstants.Vocabulary, out var vocabNode) && vocabNode is JsonObject vocabObj) { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index aa4377244..82681b210 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -1090,5 +1090,58 @@ public async Task ParseSchemaReferencePreservesVocabularySibling() referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"].Should().BeTrue(); referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"].Should().BeFalse(); } + + [Fact] + public async Task ParseSchemaReferencePreservesDynamicAnchorInsideDefsInAllOf() + { + // Arrange — the allOf-based binding variant: $defs sits inside allOf[0], + // and the nested schema has $ref + $dynamicAnchor (the binding entry). + // This was called out as a real-world pattern that hits the same root cause + // because the inner schema is an OpenApiSchemaReference whose sibling was dropped. + var yaml = """ + openapi: 3.1.0 + info: + title: allOf binding variant + version: 1.0.0 + paths: {} + components: + schemas: + Asset: + type: object + properties: + id: + type: string + Paged: + type: object + properties: + items: + type: array + AssetPaged: + allOf: + - $defs: + contentType: + $dynamicAnchor: contentType + $ref: '#/components/schemas/Asset' + - $ref: '#/components/schemas/Paged' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var assetPaged = result.Document.Components!.Schemas["AssetPaged"]; + + // Assert — the binding entry inside $defs/allOf[0] is reachable + // allOf[0] is a regular OpenApiSchema (no $ref at top level), so $defs is parsed normally. + // The nested contentType schema is an OpenApiSchemaReference ($ref: Asset), + // and its $dynamicAnchor sibling must be preserved. + assetPaged.AllOf.Should().NotBeNull(); + assetPaged.AllOf!.Count.Should().Be(2); + var defsHolder = assetPaged.AllOf[0]; + defsHolder.Definitions.Should().NotBeNull(); + defsHolder.Definitions!.Should().ContainKey("contentType"); + var contentType = defsHolder.Definitions["contentType"]; + contentType.Should().BeOfType(); + contentType.DynamicAnchor.Should().Be("contentType"); + } } } From 32fb216896876e32798c6773c8765691eef3abdf Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 19 Jun 2026 18:57:56 +0300 Subject: [PATCH 3/7] fix: add $schema sibling preservation and fix $defs location tracking Add $schema dialect URI as a sibling override on JsonSchemaReference, matching the pattern used for the other JSON Schema 2020-12 keywords. Also fix the $defs parsing loop in V31/V32 LoadSchema to push/pop the parsing context location stack (context.StartObject/EndObject) around each LoadSchema call, mirroring JsonNodeHelper.CreateMap. Without this, nested schemas inside a reference's $defs get incorrect nodeLocation values, breaking relative $ref resolution and source-pointer diagnostics. Adds a scalar round-trip test covering $id, $schema, $comment, $anchor, $dynamicRef serialization. Ref: microsoft/OpenAPI.NET#2895 --- .../Models/JsonSchemaReference.cs | 13 ++++++ .../References/OpenApiSchemaReference.cs | 2 +- src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 2 + .../Reader/V31/OpenApiSchemaDeserializer.cs | 8 +++- .../Reader/V32/OpenApiSchemaDeserializer.cs | 8 +++- .../V31Tests/OpenApiSchemaTests.cs | 45 +++++++++++++++++++ 6 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs index 91a688e37..19756cfcf 100644 --- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs @@ -64,6 +64,11 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription /// public string? SchemaId { get; set; } + /// + /// The $schema dialect URI which by default SHOULD override that of the referenced component. + /// + public Uri? Schema { get; set; } + /// /// A $comment which by default SHOULD override that of the referenced component. /// @@ -113,6 +118,7 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference) Examples = reference.Examples; Extensions = reference.Extensions != null ? new Dictionary(reference.Extensions) : null; SchemaId = reference.SchemaId; + Schema = reference.Schema; Comment = reference.Comment; Vocabulary = reference.Vocabulary != null ? new Dictionary(reference.Vocabulary) : null; DynamicRef = reference.DynamicRef; @@ -140,6 +146,7 @@ private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, OpenApiSpec // JSON Schema 2020-12 keyword siblings (preserved per OAS 3.1+ / JSON Schema 2020-12 semantics) writer.WriteProperty(OpenApiConstants.Id, SchemaId); + writer.WriteProperty(OpenApiConstants.DollarSchema, Schema?.ToString()); writer.WriteProperty(OpenApiConstants.Comment, Comment); writer.WriteOptionalMap(OpenApiConstants.Vocabulary, Vocabulary, (w, s) => w.WriteValue(s)); if (version == OpenApiSpecVersion.OpenApi3_1) @@ -233,6 +240,12 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject SchemaId = id; } + var schemaValue = GetPropertyValueFromNode(jsonObject, OpenApiConstants.DollarSchema); + if (!string.IsNullOrEmpty(schemaValue) && Uri.TryCreate(schemaValue, UriKind.Absolute, out var schemaUri)) + { + Schema = schemaUri; + } + var comment = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Comment); if (!string.IsNullOrEmpty(comment)) { diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index eee73b86e..80f16c655 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -49,7 +49,7 @@ public string? Title set => Reference.Title = value; } /// - public Uri? Schema { get => Target?.Schema; } + public Uri? Schema { get => Reference.Schema ?? Target?.Schema; } /// public string? Id { get => string.IsNullOrEmpty(Reference.SchemaId) ? Target?.Id : Reference.SchemaId; } /// diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index be46d5e0d..205a5af0e 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -9,6 +9,8 @@ Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.get -> string? Microsoft.OpenApi.JsonSchemaReference.DynamicAnchor.set -> void Microsoft.OpenApi.JsonSchemaReference.DynamicRef.get -> string? Microsoft.OpenApi.JsonSchemaReference.DynamicRef.set -> void +Microsoft.OpenApi.JsonSchemaReference.Schema.get -> System.Uri? +Microsoft.OpenApi.JsonSchemaReference.Schema.set -> void Microsoft.OpenApi.JsonSchemaReference.SchemaId.get -> string? Microsoft.OpenApi.JsonSchemaReference.SchemaId.set -> void Microsoft.OpenApi.JsonSchemaReference.Vocabulary.get -> System.Collections.Generic.IDictionary? diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index 22051a6a3..b7d90a7ce 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -460,10 +460,16 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var defs = new Dictionary(StringComparer.Ordinal); foreach (var kvp in defsObj) { - if (kvp.Value is not null) + if (kvp.Value is null) continue; + context.StartObject(kvp.Key); + try { defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context); } + finally + { + context.EndObject(); + } } result.Reference.Definitions = defs; } diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index 661f750ac..e093a31f8 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -460,10 +460,16 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum var defs = new Dictionary(StringComparer.Ordinal); foreach (var kvp in defsObj) { - if (kvp.Value is not null) + if (kvp.Value is null) continue; + context.StartObject(kvp.Key); + try { defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context); } + finally + { + context.EndObject(); + } } result.Reference.Definitions = defs; } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index 82681b210..040d41079 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -1039,6 +1039,7 @@ public async Task ParseSchemaReferencePreservesScalarKeywordSiblings() Referencing: $ref: '#/components/schemas/Target' $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' $comment: A comment sibling $anchor: myAnchor $dynamicRef: '#myAnchor' @@ -1052,6 +1053,50 @@ public async Task ParseSchemaReferencePreservesScalarKeywordSiblings() // Assert referencing.Should().BeOfType(); referencing.Id.Should().Be("https://example.com/referencing.json"); + referencing.Schema.Should().Be(new Uri("https://json-schema.org/draft/2020-12/schema")); + referencing.Comment.Should().Be("A comment sibling"); + ((IOpenApiSchemaMissingProperties)referencing).Anchor.Should().Be("myAnchor"); + referencing.DynamicRef.Should().Be("#myAnchor"); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Scalar round-trip + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var writer = new StringWriter(); + result.Document.SerializeAsV31(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves scalar siblings alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings); + var referencing = roundTripResult.Document.Components!.Schemas["Referencing"]; + + referencing.Should().BeOfType(); + referencing.Id.Should().Be("https://example.com/referencing.json"); + referencing.Schema.Should().Be(new Uri("https://json-schema.org/draft/2020-12/schema")); referencing.Comment.Should().Be("A comment sibling"); ((IOpenApiSchemaMissingProperties)referencing).Anchor.Should().Be("myAnchor"); referencing.DynamicRef.Should().Be("#myAnchor"); From 4c228f79ae23aca4a0ffd66ebe2200c279033193 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 19 Jun 2026 19:08:24 +0300 Subject: [PATCH 4/7] test: add V32 sibling preservation tests mirroring V31 Mirrors the 6 V31 sibling preservation tests in V32Tests, using SerializeAsV32 for the round-trip tests. Parse tests are identical since both versions share the same LoadSchema + SetAdditional31MetadataFromMapNode path. Ref: microsoft/OpenAPI.NET#2895 --- .../V32Tests/OpenApiSchemaTests.cs | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs index eea377e5f..114e6be7e 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiSchemaTests.cs @@ -786,5 +786,253 @@ public void DeserializeFalseSchemaParsesAsNotEmptySchema() Assert.Empty(schema.Not.AllOf ?? []); Assert.Empty(schema.Not.OneOf ?? []); } + + [Fact] + public async Task ParseSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + description: Sibling description + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert — siblings are preserved on the OpenApiSchemaReference + referencing.Should().BeOfType(); + referencing.Description.Should().Be("Sibling description"); + referencing.DynamicAnchor.Should().Be("anchor"); + referencing.Definitions.Should().NotBeNull(); + referencing.Definitions!.Should().ContainKey("sibling"); + referencing.Definitions["sibling"].DynamicAnchor.Should().Be("inner"); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesJsonSchema2020KeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Sibling preservation repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Target' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var writer = new StringWriter(); + result.Document.SerializeAsV32(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves $dynamicAnchor and $defs alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings); + var referencing = roundTripResult.Document.Components!.Schemas["Referencing"]; + + referencing.Should().BeOfType(); + referencing.DynamicAnchor.Should().Be("anchor"); + referencing.Definitions.Should().NotBeNull(); + referencing.Definitions!.Should().ContainKey("itemType"); + referencing.Definitions["itemType"].DynamicAnchor.Should().Be("itemType"); + } + + [Fact] + public async Task ParseSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Scalar sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert + referencing.Should().BeOfType(); + referencing.Id.Should().Be("https://example.com/referencing.json"); + referencing.Schema.Should().Be(new Uri("https://json-schema.org/draft/2020-12/schema")); + referencing.Comment.Should().Be("A comment sibling"); + ((IOpenApiSchemaMissingProperties)referencing).Anchor.Should().Be("myAnchor"); + referencing.DynamicRef.Should().Be("#myAnchor"); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesScalarKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Scalar round-trip + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $id: 'https://example.com/referencing.json' + $schema: 'https://json-schema.org/draft/2020-12/schema' + $comment: A comment sibling + $anchor: myAnchor + $dynamicRef: '#myAnchor' + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var writer = new StringWriter(); + result.Document.SerializeAsV32(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves scalar siblings alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings); + var referencing = roundTripResult.Document.Components!.Schemas["Referencing"]; + + referencing.Should().BeOfType(); + referencing.Id.Should().Be("https://example.com/referencing.json"); + referencing.Schema.Should().Be(new Uri("https://json-schema.org/draft/2020-12/schema")); + referencing.Comment.Should().Be("A comment sibling"); + ((IOpenApiSchemaMissingProperties)referencing).Anchor.Should().Be("myAnchor"); + referencing.DynamicRef.Should().Be("#myAnchor"); + } + + [Fact] + public async Task ParseSchemaReferencePreservesVocabularySibling() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Vocabulary sibling repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + 'https://json-schema.org/draft/2020-12/vocab/applicator': false + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert + referencing.Should().BeOfType(); + referencing.Vocabulary.Should().NotBeNull(); + referencing.Vocabulary!.Should().HaveCount(2); + referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"].Should().BeTrue(); + referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"].Should().BeFalse(); + } + + [Fact] + public async Task ParseSchemaReferencePreservesDynamicAnchorInsideDefsInAllOf() + { + // Arrange — the allOf-based binding variant: $defs sits inside allOf[0], + // and the nested schema has $ref + $dynamicAnchor (the binding entry). + var yaml = """ + openapi: 3.2.0 + info: + title: allOf binding variant + version: 1.0.0 + paths: {} + components: + schemas: + Asset: + type: object + properties: + id: + type: string + Paged: + type: object + properties: + items: + type: array + AssetPaged: + allOf: + - $defs: + contentType: + $dynamicAnchor: contentType + $ref: '#/components/schemas/Asset' + - $ref: '#/components/schemas/Paged' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var assetPaged = result.Document.Components!.Schemas["AssetPaged"]; + + // Assert + assetPaged.AllOf.Should().NotBeNull(); + assetPaged.AllOf!.Count.Should().Be(2); + var defsHolder = assetPaged.AllOf[0]; + defsHolder.Definitions.Should().NotBeNull(); + defsHolder.Definitions!.Should().ContainKey("contentType"); + var contentType = defsHolder.Definitions["contentType"]; + contentType.Should().BeOfType(); + contentType.DynamicAnchor.Should().Be("contentType"); + } } } From 4666f2c599247e558007e039b09452c8f77cf88d Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 19 Jun 2026 19:36:24 +0300 Subject: [PATCH 5/7] fix: don't suppress target values when sibling collections are empty An empty $defs: {} or $vocabulary: {} sibling would assign an empty collection to the reference, blocking fallthrough to Target via the ?? coalescing getter. Only assign when the collection has entries. Ref: microsoft/OpenAPI.NET#2895 --- src/Microsoft.OpenApi/Models/JsonSchemaReference.cs | 8 ++++++-- .../Reader/V31/OpenApiSchemaDeserializer.cs | 5 ++++- .../Reader/V32/OpenApiSchemaDeserializer.cs | 5 ++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs index 19756cfcf..78d46752d 100644 --- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs @@ -272,14 +272,18 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject if (jsonObject.TryGetPropertyValue(OpenApiConstants.Vocabulary, out var vocabNode) && vocabNode is JsonObject vocabObj) { - Vocabulary = new Dictionary(); + var vocab = new Dictionary(); foreach (var kvp in vocabObj) { if (kvp.Value is JsonValue v && v.TryGetValue(out var b)) { - Vocabulary[kvp.Key] = b; + vocab[kvp.Key] = b; } } + if (vocab.Count > 0) + { + Vocabulary = vocab; + } } } } diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs index b7d90a7ce..062801b1a 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -471,7 +471,10 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum context.EndObject(); } } - result.Reference.Definitions = defs; + if (defs.Count > 0) + { + result.Reference.Definitions = defs; + } } return result; diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs index e093a31f8..25c7eab8c 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -471,7 +471,10 @@ public static IOpenApiSchema LoadSchema(JsonNode node, OpenApiDocument hostDocum context.EndObject(); } } - result.Reference.Definitions = defs; + if (defs.Count > 0) + { + result.Reference.Definitions = defs; + } } return result; From fcf4dbf0e2720398c56926588dd29b4afa216216 Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Fri, 19 Jun 2026 20:58:18 +0300 Subject: [PATCH 6/7] test: add empty-collection fallthrough, 3.0 version safety, and $vocabulary round-trip tests - Empty $defs: {} / $vocabulary: {} must fall through to Target (guards the .Count > 0 fix in commit 4666f2c5) - 3.0 document with $ref + siblings must drop siblings per spec (guards the version-safety guarantee) - $vocabulary round-trip (parse -> serialize -> parse) Ref: microsoft/OpenAPI.NET#2895 --- .../V31Tests/OpenApiSchemaTests.cs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index 040d41079..b183a9f0a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -1188,5 +1188,122 @@ public async Task ParseSchemaReferencePreservesDynamicAnchorInsideDefsInAllOf() contentType.Should().BeOfType(); contentType.DynamicAnchor.Should().Be("contentType"); } + + [Fact] + public async Task EmptySiblingCollectionsFallThroughToTarget() + { + // Arrange — Target has $defs and $vocabulary; Referencing has empty siblings. + // Empty collections must NOT suppress the target's values via the ?? getter. + var yaml = """ + openapi: 3.1.0 + info: + title: Empty sibling fallthrough + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + $defs: + targetDef: + type: string + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + Referencing: + $ref: '#/components/schemas/Target' + $defs: {} + $vocabulary: {} + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert — empty siblings fall through to Target's values + referencing.Should().BeOfType(); + referencing.Definitions.Should().NotBeNull(); + referencing.Definitions!.Should().ContainKey("targetDef"); + referencing.Vocabulary.Should().NotBeNull(); + referencing.Vocabulary!.Should().ContainKey("https://json-schema.org/draft/2020-12/vocab/core"); + } + + [Fact] + public async Task SiblingsOnRefAreDroppedForOpenApi30() + { + // Arrange — 3.0 spec requires $ref siblings to be ignored. + // The fix must not change 3.0 behavior. + var yaml = """ + openapi: 3.0.3 + info: + title: 3.0 version safety + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + properties: + name: + type: string + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + + // Assert — siblings are dropped for 3.0 (per spec: $ref siblings MUST be ignored) + referencing.Should().BeOfType(); + referencing.DynamicAnchor.Should().BeNull(); + referencing.Definitions?.Should().BeNull(); + } + + [Fact] + public async Task SerializeSchemaReferencePreservesVocabularySibling() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Vocabulary round-trip + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $vocabulary: + 'https://json-schema.org/draft/2020-12/vocab/core': true + 'https://json-schema.org/draft/2020-12/vocab/applicator': false + """; + + // Act — parse then serialize back + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var writer = new StringWriter(); + result.Document.SerializeAsV31(new OpenApiYamlWriter(writer)); + var output = writer.ToString(); + + // Assert — round-trip preserves $vocabulary alongside $ref + using var roundTripStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(output)); + var roundTripResult = await OpenApiDocument.LoadAsync(roundTripStream, "yaml", SettingsFixture.ReaderSettings); + var referencing = roundTripResult.Document.Components!.Schemas["Referencing"]; + + referencing.Should().BeOfType(); + referencing.Vocabulary.Should().NotBeNull(); + referencing.Vocabulary!.Should().HaveCount(2); + referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"].Should().BeTrue(); + referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"].Should().BeFalse(); + } } } From af0686aadfd768aebf1e17b276e5ac2e00ccc2dd Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Sat, 20 Jun 2026 23:53:50 +0300 Subject: [PATCH 7/7] test: add CreateShallowCopy test to cover JsonSchemaReference copy constructor Achieves 100% diff coverage on all changed files. Ref: microsoft/OpenAPI.NET#2895 --- .../V31Tests/OpenApiSchemaTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs index b183a9f0a..e8c15cd2a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -1305,5 +1305,42 @@ public async Task SerializeSchemaReferencePreservesVocabularySibling() referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/core"].Should().BeTrue(); referencing.Vocabulary["https://json-schema.org/draft/2020-12/vocab/applicator"].Should().BeFalse(); } + + [Fact] + public async Task CreateShallowCopyPreservesKeywordSiblings() + { + // Arrange + var yaml = """ + openapi: 3.1.0 + info: + title: Shallow copy repro + version: 1.0.0 + paths: {} + components: + schemas: + Target: + type: object + Referencing: + $ref: '#/components/schemas/Target' + $dynamicAnchor: anchor + $defs: + sibling: + $dynamicAnchor: inner + $ref: '#/components/schemas/Target' + """; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = await OpenApiDocument.LoadAsync(stream, "yaml", SettingsFixture.ReaderSettings); + var referencing = result.Document.Components!.Schemas["Referencing"]; + var copy = referencing.CreateShallowCopy(); + + // Assert — CreateShallowCopy preserves sibling values via the JsonSchemaReference copy constructor + copy.Should().BeOfType(); + copy.DynamicAnchor.Should().Be("anchor"); + copy.Definitions.Should().NotBeNull(); + copy.Definitions!.Should().ContainKey("sibling"); + copy.Definitions["sibling"].DynamicAnchor.Should().Be("inner"); + } } }