diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs index 933a4a85b..443a47ab6 100644 --- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs @@ -52,6 +52,12 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription /// public IList? Examples { get; set; } + /// + /// Extension data for this schema reference. Only allowed in OpenAPI 3.1 and later. + /// Extensions are NOT written when serializing for OpenAPI 2.0 or 3.0. + /// + public IDictionary? Extensions { get; set; } + /// /// Parameterless constructor /// @@ -69,6 +75,7 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference) ReadOnly = reference.ReadOnly; WriteOnly = reference.WriteOnly; Examples = reference.Examples; + Extensions = reference.Extensions != null ? new Dictionary(reference.Extensions) : null; } /// @@ -106,6 +113,7 @@ private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action w.WriteAny(e)); } + writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi3_1); } /// @@ -146,5 +154,16 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject { Examples = examplesArray.OfType().ToList(); } + + // Extensions (properties starting with "x-") + foreach (var property in jsonObject) + { + if (property.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase) + && property.Value is JsonNode extensionValue) + { + Extensions ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone()); + } + } } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 55be30a51..67eb79645 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -10,7 +10,7 @@ namespace Microsoft.OpenApi /// /// Schema reference object /// - public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties + public class OpenApiSchemaReference : BaseOpenApiReferenceHolder, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible { /// @@ -158,7 +158,11 @@ public bool Deprecated /// public OpenApiXml? Xml { get => Target?.Xml; } /// - public IDictionary? Extensions { get => Target?.Extensions; } + public IDictionary? Extensions + { + get => Reference.Extensions ?? Target?.Extensions; + set => Reference.Extensions = value; + } /// public IDictionary? UnrecognizedKeywords { get => Target?.UnrecognizedKeywords; } @@ -172,6 +176,12 @@ public override void SerializeAsV31(IOpenApiWriter writer) SerializeAsWithoutLoops(writer, (w, element) => (element is IOpenApiSchema s ? CopyReferenceAsTargetElementWithOverrides(s) : element).SerializeAsV31(w)); } + /// + public override void SerializeAsV32(IOpenApiWriter writer) + { + SerializeAsWithoutLoops(writer, (w, element) => (element is IOpenApiSchema s ? CopyReferenceAsTargetElementWithOverrides(s) : element).SerializeAsV32(w)); + } + /// public override void SerializeAsV3(IOpenApiWriter writer) { diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..7b7b54aca 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +Microsoft.OpenApi.JsonSchemaReference.Extensions.get -> System.Collections.Generic.IDictionary? +Microsoft.OpenApi.JsonSchemaReference.Extensions.set -> void +Microsoft.OpenApi.OpenApiSchemaReference.Extensions.set -> void +override Microsoft.OpenApi.OpenApiSchemaReference.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=False.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=False.verified.txt new file mode 100644 index 000000000..ddb324ad6 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=False.verified.txt @@ -0,0 +1,3 @@ +{ + "$ref": "#/definitions/Pet" +} diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=True.verified.txt new file mode 100644 index 000000000..b36112c01 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV2JsonWorks_produceTerseOutput=True.verified.txt @@ -0,0 +1 @@ +{"$ref":"#/definitions/Pet"} diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=False.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=False.verified.txt index 3d7372e1b..87dbbe91f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=False.verified.txt +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=False.verified.txt @@ -7,5 +7,6 @@ "examples": [ "reference example" ], + "x-custom": "custom value", "$ref": "#/components/schemas/Pet" } \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=True.verified.txt index 2a7cc8e44..c7f7f3cd0 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=True.verified.txt +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=True.verified.txt @@ -1 +1 @@ -{"description":"Reference Description","default":"reference default","title":"Reference Title","deprecated":true,"readOnly":true,"examples":["reference example"],"$ref":"#/components/schemas/Pet"} \ No newline at end of file +{"description":"Reference Description","default":"reference default","title":"Reference Title","deprecated":true,"readOnly":true,"examples":["reference example"],"x-custom":"custom value","$ref":"#/components/schemas/Pet"} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV32JsonWorks_produceTerseOutput=False.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV32JsonWorks_produceTerseOutput=False.verified.txt new file mode 100644 index 000000000..87dbbe91f --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV32JsonWorks_produceTerseOutput=False.verified.txt @@ -0,0 +1,12 @@ +{ + "description": "Reference Description", + "default": "reference default", + "title": "Reference Title", + "deprecated": true, + "readOnly": true, + "examples": [ + "reference example" + ], + "x-custom": "custom value", + "$ref": "#/components/schemas/Pet" +} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV32JsonWorks_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV32JsonWorks_produceTerseOutput=True.verified.txt new file mode 100644 index 000000000..c7f7f3cd0 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV32JsonWorks_produceTerseOutput=True.verified.txt @@ -0,0 +1 @@ +{"description":"Reference Description","default":"reference default","title":"Reference Title","deprecated":true,"readOnly":true,"examples":["reference example"],"x-custom":"custom value","$ref":"#/components/schemas/Pet"} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs index 57ccae0cb..488488518 100644 --- a/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs @@ -133,7 +133,11 @@ public async Task SerializeSchemaReferenceAsV31JsonWorks(bool produceTerseOutput WriteOnly = false, Deprecated = true, Default = JsonValue.Create("reference default"), - Examples = new List { JsonValue.Create("reference example") } + Examples = new List { JsonValue.Create("reference example") }, + Extensions = new Dictionary + { + ["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value")) + } }; var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); @@ -150,7 +154,7 @@ public async Task SerializeSchemaReferenceAsV31JsonWorks(bool produceTerseOutput [Theory] [InlineData(true)] [InlineData(false)] - public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput) + public async Task SerializeSchemaReferenceAsV32JsonWorks(bool produceTerseOutput) { // Arrange var reference = new OpenApiSchemaReference("Pet", null) @@ -161,7 +165,43 @@ public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput) WriteOnly = false, Deprecated = true, Default = JsonValue.Create("reference default"), - Examples = new List { JsonValue.Create("reference example") } + Examples = new List { JsonValue.Create("reference example") }, + Extensions = new Dictionary + { + ["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value")) + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = produceTerseOutput }); + + // Act + reference.SerializeAsV32(writer); + await writer.FlushAsync(); + + // Assert + await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput) + { + // Arrange - Extensions should NOT appear in v3.0 output + var reference = new OpenApiSchemaReference("Pet", null) + { + Title = "Reference Title", + Description = "Reference Description", + ReadOnly = true, + WriteOnly = false, + Deprecated = true, + Default = JsonValue.Create("reference default"), + Examples = new List { JsonValue.Create("reference example") }, + Extensions = new Dictionary + { + ["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value")) + } }; var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); @@ -175,6 +215,38 @@ public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput) await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SerializeSchemaReferenceAsV2JsonWorks(bool produceTerseOutput) + { + // Arrange - Extensions should NOT appear in v2 output + var reference = new OpenApiSchemaReference("Pet", null) + { + Title = "Reference Title", + Description = "Reference Description", + ReadOnly = true, + WriteOnly = false, + Deprecated = true, + Default = JsonValue.Create("reference default"), + Examples = new List { JsonValue.Create("reference example") }, + Extensions = new Dictionary + { + ["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value")) + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = produceTerseOutput }); + + // Act + reference.SerializeAsV2(writer); + await writer.FlushAsync(); + + // Assert + await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); + } + [Fact] public void ParseSchemaReferenceWithAnnotationsWorks() { @@ -256,5 +328,120 @@ public void ParseSchemaReferenceWithAnnotationsWorks() Assert.Equal("Original Pet Title", targetSchema.Title); Assert.Equal("Original Pet Description", targetSchema.Description); } + + [Fact] + public void ParseSchemaReferenceWithExtensionsWorks() + { + // Arrange + var jsonContent = @"{ + ""openapi"": ""3.1.0"", + ""info"": { + ""title"": ""Test API"", + ""version"": ""1.0.0"" + }, + ""paths"": { + ""/test"": { + ""get"": { + ""responses"": { + ""200"": { + ""description"": ""OK"", + ""content"": { + ""application/json"": { + ""schema"": { + ""$ref"": ""#/components/schemas/Pet"", + ""description"": ""A pet object"", + ""x-custom-extension"": ""custom value"", + ""x-another-extension"": 42 + } + } + } + } + } + } + } + }, + ""components"": { + ""schemas"": { + ""Pet"": { + ""type"": ""object"", + ""properties"": { + ""name"": { + ""type"": ""string"" + } + } + } + } + } +}"; + + // Act + var readResult = OpenApiDocument.Parse(jsonContent, "json"); + var document = readResult.Document; + + // Assert + Assert.NotNull(document); + Assert.Empty(readResult.Diagnostic.Errors); + + var schema = document.Paths["/test"].Operations[HttpMethod.Get] + .Responses["200"].Content["application/json"].Schema; + + Assert.IsType(schema); + var schemaRef = (OpenApiSchemaReference)schema; + + // Test that reference-level extensions are parsed + Assert.NotNull(schemaRef.Extensions); + Assert.Contains("x-custom-extension", schemaRef.Extensions.Keys); + Assert.Contains("x-another-extension", schemaRef.Extensions.Keys); + } + + [Fact] + public async Task SchemaReferenceExtensionsNotWrittenInV30() + { + // Arrange + var reference = new OpenApiSchemaReference("Pet", null) + { + Description = "Local description", + Extensions = new Dictionary + { + ["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value")) + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true }); + + // Act + reference.SerializeAsV3(writer); + await writer.FlushAsync(); + var output = outputStringWriter.ToString(); + + // Assert: In v3.0, ONLY $ref should appear - no description, no extensions + Assert.Equal(@"{""$ref"":""#/components/schemas/Pet""}", output); + } + + [Fact] + public async Task SchemaReferenceExtensionsNotWrittenInV2() + { + // Arrange + var reference = new OpenApiSchemaReference("Pet", null) + { + Description = "Local description", + Extensions = new Dictionary + { + ["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value")) + } + }; + + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true }); + + // Act + reference.SerializeAsV2(writer); + await writer.FlushAsync(); + var output = outputStringWriter.ToString(); + + // Assert: In v2, ONLY $ref should appear - no description, no extensions + Assert.Equal(@"{""$ref"":""#/definitions/Pet""}", output); + } } }