Skip to content

Commit 9bf61de

Browse files
authored
Merge pull request #2782 from microsoft/copilot/fix-openapi-schema-reference-description
fix(library): enforce spec-compliant $ref serialization; add Extensions support for schema references in v3.1/v3.2
2 parents ef55b2c + 45dc6b3 commit 9bf61de

10 files changed

+244
-7
lines changed

src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

44
using System;
@@ -52,6 +52,12 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
5252
/// </summary>
5353
public IList<JsonNode>? Examples { get; set; }
5454

55+
/// <summary>
56+
/// Extension data for this schema reference. Only allowed in OpenAPI 3.1 and later.
57+
/// Extensions are NOT written when serializing for OpenAPI 2.0 or 3.0.
58+
/// </summary>
59+
public IDictionary<string, IOpenApiExtension>? Extensions { get; set; }
60+
5561
/// <summary>
5662
/// Parameterless constructor
5763
/// </summary>
@@ -69,6 +75,7 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference)
6975
ReadOnly = reference.ReadOnly;
7076
WriteOnly = reference.WriteOnly;
7177
Examples = reference.Examples;
78+
Extensions = reference.Extensions != null ? new Dictionary<string, IOpenApiExtension>(reference.Extensions) : null;
7279
}
7380

7481
/// <inheritdoc/>
@@ -106,6 +113,7 @@ private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action<IOpe
106113
{
107114
writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (w, e) => w.WriteAny(e));
108115
}
116+
writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi3_1);
109117
}
110118

111119
/// <inheritdoc/>
@@ -146,5 +154,15 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject
146154
{
147155
Examples = examplesArray.OfType<JsonNode>().ToList();
148156
}
157+
158+
// Extensions (properties starting with "x-")
159+
foreach (var property in jsonObject
160+
.Where(static p => p.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)
161+
&& p.Value is not null))
162+
{
163+
var extensionValue = property.Value!;
164+
Extensions ??= new Dictionary<string, IOpenApiExtension>(StringComparer.OrdinalIgnoreCase);
165+
Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone());
166+
}
149167
}
150168
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace Microsoft.OpenApi
1010
/// <summary>
1111
/// Schema reference object
1212
/// </summary>
13-
public class OpenApiSchemaReference : BaseOpenApiReferenceHolder<OpenApiSchema, IOpenApiSchema, JsonSchemaReference>, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties
13+
public class OpenApiSchemaReference : BaseOpenApiReferenceHolder<OpenApiSchema, IOpenApiSchema, JsonSchemaReference>, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible
1414
{
1515

1616
/// <summary>
@@ -158,7 +158,11 @@ public bool Deprecated
158158
/// <inheritdoc/>
159159
public OpenApiXml? Xml { get => Target?.Xml; }
160160
/// <inheritdoc/>
161-
public IDictionary<string, IOpenApiExtension>? Extensions { get => Target?.Extensions; }
161+
public IDictionary<string, IOpenApiExtension>? Extensions
162+
{
163+
get => Reference.Extensions ?? Target?.Extensions;
164+
set => Reference.Extensions = value;
165+
}
162166

163167
/// <inheritdoc/>
164168
public IDictionary<string, JsonNode>? UnrecognizedKeywords { get => Target?.UnrecognizedKeywords; }
@@ -172,6 +176,12 @@ public override void SerializeAsV31(IOpenApiWriter writer)
172176
SerializeAsWithoutLoops(writer, (w, element) => (element is IOpenApiSchema s ? CopyReferenceAsTargetElementWithOverrides(s) : element).SerializeAsV31(w));
173177
}
174178

179+
/// <inheritdoc/>
180+
public override void SerializeAsV32(IOpenApiWriter writer)
181+
{
182+
SerializeAsWithoutLoops(writer, (w, element) => (element is IOpenApiSchema s ? CopyReferenceAsTargetElementWithOverrides(s) : element).SerializeAsV32(w));
183+
}
184+
175185
/// <inheritdoc/>
176186
public override void SerializeAsV3(IOpenApiWriter writer)
177187
{
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
#nullable enable
2+
Microsoft.OpenApi.JsonSchemaReference.Extensions.get -> System.Collections.Generic.IDictionary<string!, Microsoft.OpenApi.IOpenApiExtension!>?
3+
Microsoft.OpenApi.JsonSchemaReference.Extensions.set -> void
4+
Microsoft.OpenApi.OpenApiSchemaReference.Extensions.set -> void
5+
override Microsoft.OpenApi.OpenApiSchemaReference.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"$ref": "#/definitions/Pet"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"$ref":"#/definitions/Pet"}

test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.SerializeSchemaReferenceAsV31JsonWorks_produceTerseOutput=False.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
"examples": [
88
"reference example"
99
],
10+
"x-custom": "custom value",
1011
"$ref": "#/components/schemas/Pet"
1112
}
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"description":"Reference Description","default":"reference default","title":"Reference Title","deprecated":true,"readOnly":true,"examples":["reference example"],"$ref":"#/components/schemas/Pet"}
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"}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"description": "Reference Description",
3+
"default": "reference default",
4+
"title": "Reference Title",
5+
"deprecated": true,
6+
"readOnly": true,
7+
"examples": [
8+
"reference example"
9+
],
10+
"x-custom": "custom value",
11+
"$ref": "#/components/schemas/Pet"
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}

test/Microsoft.OpenApi.Tests/Models/References/OpenApiSchemaReferenceTests.cs

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,11 @@ public async Task SerializeSchemaReferenceAsV31JsonWorks(bool produceTerseOutput
133133
WriteOnly = false,
134134
Deprecated = true,
135135
Default = JsonValue.Create("reference default"),
136-
Examples = new List<JsonNode> { JsonValue.Create("reference example") }
136+
Examples = new List<JsonNode> { JsonValue.Create("reference example") },
137+
Extensions = new Dictionary<string, IOpenApiExtension>
138+
{
139+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
140+
}
137141
};
138142

139143
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
@@ -150,7 +154,7 @@ public async Task SerializeSchemaReferenceAsV31JsonWorks(bool produceTerseOutput
150154
[Theory]
151155
[InlineData(true)]
152156
[InlineData(false)]
153-
public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput)
157+
public async Task SerializeSchemaReferenceAsV32JsonWorks(bool produceTerseOutput)
154158
{
155159
// Arrange
156160
var reference = new OpenApiSchemaReference("Pet", null)
@@ -161,7 +165,43 @@ public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput)
161165
WriteOnly = false,
162166
Deprecated = true,
163167
Default = JsonValue.Create("reference default"),
164-
Examples = new List<JsonNode> { JsonValue.Create("reference example") }
168+
Examples = new List<JsonNode> { JsonValue.Create("reference example") },
169+
Extensions = new Dictionary<string, IOpenApiExtension>
170+
{
171+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
172+
}
173+
};
174+
175+
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
176+
var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = produceTerseOutput });
177+
178+
// Act
179+
reference.SerializeAsV32(writer);
180+
await writer.FlushAsync();
181+
182+
// Assert
183+
await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput);
184+
}
185+
186+
[Theory]
187+
[InlineData(true)]
188+
[InlineData(false)]
189+
public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput)
190+
{
191+
// Arrange - Extensions should NOT appear in v3.0 output
192+
var reference = new OpenApiSchemaReference("Pet", null)
193+
{
194+
Title = "Reference Title",
195+
Description = "Reference Description",
196+
ReadOnly = true,
197+
WriteOnly = false,
198+
Deprecated = true,
199+
Default = JsonValue.Create("reference default"),
200+
Examples = new List<JsonNode> { JsonValue.Create("reference example") },
201+
Extensions = new Dictionary<string, IOpenApiExtension>
202+
{
203+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
204+
}
165205
};
166206

167207
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
@@ -175,6 +215,38 @@ public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput)
175215
await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput);
176216
}
177217

218+
[Theory]
219+
[InlineData(true)]
220+
[InlineData(false)]
221+
public async Task SerializeSchemaReferenceAsV2JsonWorks(bool produceTerseOutput)
222+
{
223+
// Arrange - Extensions should NOT appear in v2 output
224+
var reference = new OpenApiSchemaReference("Pet", null)
225+
{
226+
Title = "Reference Title",
227+
Description = "Reference Description",
228+
ReadOnly = true,
229+
WriteOnly = false,
230+
Deprecated = true,
231+
Default = JsonValue.Create("reference default"),
232+
Examples = new List<JsonNode> { JsonValue.Create("reference example") },
233+
Extensions = new Dictionary<string, IOpenApiExtension>
234+
{
235+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
236+
}
237+
};
238+
239+
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
240+
var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = produceTerseOutput });
241+
242+
// Act
243+
reference.SerializeAsV2(writer);
244+
await writer.FlushAsync();
245+
246+
// Assert
247+
await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput);
248+
}
249+
178250
[Fact]
179251
public void ParseSchemaReferenceWithAnnotationsWorks()
180252
{
@@ -256,5 +328,120 @@ public void ParseSchemaReferenceWithAnnotationsWorks()
256328
Assert.Equal("Original Pet Title", targetSchema.Title);
257329
Assert.Equal("Original Pet Description", targetSchema.Description);
258330
}
331+
332+
[Fact]
333+
public void ParseSchemaReferenceWithExtensionsWorks()
334+
{
335+
// Arrange
336+
var jsonContent = @"{
337+
""openapi"": ""3.1.0"",
338+
""info"": {
339+
""title"": ""Test API"",
340+
""version"": ""1.0.0""
341+
},
342+
""paths"": {
343+
""/test"": {
344+
""get"": {
345+
""responses"": {
346+
""200"": {
347+
""description"": ""OK"",
348+
""content"": {
349+
""application/json"": {
350+
""schema"": {
351+
""$ref"": ""#/components/schemas/Pet"",
352+
""description"": ""A pet object"",
353+
""x-custom-extension"": ""custom value"",
354+
""x-another-extension"": 42
355+
}
356+
}
357+
}
358+
}
359+
}
360+
}
361+
}
362+
},
363+
""components"": {
364+
""schemas"": {
365+
""Pet"": {
366+
""type"": ""object"",
367+
""properties"": {
368+
""name"": {
369+
""type"": ""string""
370+
}
371+
}
372+
}
373+
}
374+
}
375+
}";
376+
377+
// Act
378+
var readResult = OpenApiDocument.Parse(jsonContent, "json");
379+
var document = readResult.Document;
380+
381+
// Assert
382+
Assert.NotNull(document);
383+
Assert.Empty(readResult.Diagnostic.Errors);
384+
385+
var schema = document.Paths["/test"].Operations[HttpMethod.Get]
386+
.Responses["200"].Content["application/json"].Schema;
387+
388+
Assert.IsType<OpenApiSchemaReference>(schema);
389+
var schemaRef = (OpenApiSchemaReference)schema;
390+
391+
// Test that reference-level extensions are parsed
392+
Assert.NotNull(schemaRef.Extensions);
393+
Assert.Contains("x-custom-extension", schemaRef.Extensions.Keys);
394+
Assert.Contains("x-another-extension", schemaRef.Extensions.Keys);
395+
}
396+
397+
[Fact]
398+
public async Task SchemaReferenceExtensionsNotWrittenInV30()
399+
{
400+
// Arrange
401+
var reference = new OpenApiSchemaReference("Pet", null)
402+
{
403+
Description = "Local description",
404+
Extensions = new Dictionary<string, IOpenApiExtension>
405+
{
406+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
407+
}
408+
};
409+
410+
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
411+
var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true });
412+
413+
// Act
414+
reference.SerializeAsV3(writer);
415+
await writer.FlushAsync();
416+
var output = outputStringWriter.ToString();
417+
418+
// Assert: In v3.0, ONLY $ref should appear - no description, no extensions
419+
Assert.Equal(@"{""$ref"":""#/components/schemas/Pet""}", output);
420+
}
421+
422+
[Fact]
423+
public async Task SchemaReferenceExtensionsNotWrittenInV2()
424+
{
425+
// Arrange
426+
var reference = new OpenApiSchemaReference("Pet", null)
427+
{
428+
Description = "Local description",
429+
Extensions = new Dictionary<string, IOpenApiExtension>
430+
{
431+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
432+
}
433+
};
434+
435+
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
436+
var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true });
437+
438+
// Act
439+
reference.SerializeAsV2(writer);
440+
await writer.FlushAsync();
441+
var output = outputStringWriter.ToString();
442+
443+
// Assert: In v2, ONLY $ref should appear - no description, no extensions
444+
Assert.Equal(@"{""$ref"":""#/definitions/Pet""}", output);
445+
}
259446
}
260447
}

0 commit comments

Comments
 (0)