diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs index fff77e4cc..78d46752d 100644 --- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs @@ -58,6 +58,47 @@ 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; } + + /// + /// 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. + /// + 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 +117,50 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference) WriteOnly = reference.WriteOnly; 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; + 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.DollarSchema, Schema?.ToString()); + 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 +231,59 @@ 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) + var id = GetPropertyValueFromNode(jsonObject, OpenApiConstants.Id); + if (!string.IsNullOrEmpty(id)) + { + 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)) + { + 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) + { + var vocab = new Dictionary(); + foreach (var kvp in vocabObj) + { + if (kvp.Value is JsonValue v && v.TryGetValue(out var b)) + { + vocab[kvp.Key] = b; + } + } + if (vocab.Count > 0) + { + Vocabulary = vocab; + } + } } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 8601e1bd8..80f16c655 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -49,21 +49,21 @@ 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 => 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..205a5af0e 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,17 @@ #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.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? +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..062801b1a 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs @@ -452,6 +452,31 @@ 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 null) continue; + context.StartObject(kvp.Key); + try + { + defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context); + } + finally + { + context.EndObject(); + } + } + 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 9d35aaf5a..25c7eab8c 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiSchemaDeserializer.cs @@ -452,6 +452,31 @@ 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 null) continue; + context.StartObject(kvp.Key); + try + { + defs[kvp.Key] = LoadSchema(kvp.Value, hostDocument, context); + } + finally + { + context.EndObject(); + } + } + if (defs.Count > 0) + { + 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..e8c15cd2a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiSchemaTests.cs @@ -935,5 +935,412 @@ 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' + $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.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"); + } + + [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(); + } + + [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"); + } + + [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(); + } + + [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"); + } } } 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"); + } } }