Skip to content

Commit 81e7a9c

Browse files
Copilotbaywet
andcommitted
feat(library): add Extensions support for schema references in v3.1/v3.2; add SerializeAsV32 with loop detection
- Add Extensions property to JsonSchemaReference (serialize in v3.1/v3.2 only) - Add Extensions setter to OpenApiSchemaReference (checks reference-level extensions first) - Add SerializeAsV32 override to OpenApiSchemaReference with loop detection - Read extension properties from $ref sibling keywords during v3.1 deserialization - Add tests for extensions in v3.1/v3.2 and verify extensions dropped in v3.0/v2 Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
1 parent 9d7a19c commit 81e7a9c

8 files changed

+207
-5
lines changed

src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,16 @@ 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+
{
161+
if (property.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)
162+
&& property.Value is JsonNode extensionValue)
163+
{
164+
Extensions ??= new Dictionary<string, IOpenApiExtension>(StringComparer.OrdinalIgnoreCase);
165+
Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone());
166+
}
167+
}
149168
}
150169
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

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: 158 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);
@@ -256,5 +296,120 @@ public void ParseSchemaReferenceWithAnnotationsWorks()
256296
Assert.Equal("Original Pet Title", targetSchema.Title);
257297
Assert.Equal("Original Pet Description", targetSchema.Description);
258298
}
299+
300+
[Fact]
301+
public void ParseSchemaReferenceWithExtensionsWorks()
302+
{
303+
// Arrange
304+
var jsonContent = @"{
305+
""openapi"": ""3.1.0"",
306+
""info"": {
307+
""title"": ""Test API"",
308+
""version"": ""1.0.0""
309+
},
310+
""paths"": {
311+
""/test"": {
312+
""get"": {
313+
""responses"": {
314+
""200"": {
315+
""description"": ""OK"",
316+
""content"": {
317+
""application/json"": {
318+
""schema"": {
319+
""$ref"": ""#/components/schemas/Pet"",
320+
""description"": ""A pet object"",
321+
""x-custom-extension"": ""custom value"",
322+
""x-another-extension"": 42
323+
}
324+
}
325+
}
326+
}
327+
}
328+
}
329+
}
330+
},
331+
""components"": {
332+
""schemas"": {
333+
""Pet"": {
334+
""type"": ""object"",
335+
""properties"": {
336+
""name"": {
337+
""type"": ""string""
338+
}
339+
}
340+
}
341+
}
342+
}
343+
}";
344+
345+
// Act
346+
var readResult = OpenApiDocument.Parse(jsonContent, "json");
347+
var document = readResult.Document;
348+
349+
// Assert
350+
Assert.NotNull(document);
351+
Assert.Empty(readResult.Diagnostic.Errors);
352+
353+
var schema = document.Paths["/test"].Operations[HttpMethod.Get]
354+
.Responses["200"].Content["application/json"].Schema;
355+
356+
Assert.IsType<OpenApiSchemaReference>(schema);
357+
var schemaRef = (OpenApiSchemaReference)schema;
358+
359+
// Test that reference-level extensions are parsed
360+
Assert.NotNull(schemaRef.Extensions);
361+
Assert.Contains("x-custom-extension", schemaRef.Extensions.Keys);
362+
Assert.Contains("x-another-extension", schemaRef.Extensions.Keys);
363+
}
364+
365+
[Fact]
366+
public async Task SchemaReferenceExtensionsNotWrittenInV30()
367+
{
368+
// Arrange
369+
var reference = new OpenApiSchemaReference("Pet", null)
370+
{
371+
Description = "Local description",
372+
Extensions = new Dictionary<string, IOpenApiExtension>
373+
{
374+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
375+
}
376+
};
377+
378+
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
379+
var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true });
380+
381+
// Act
382+
reference.SerializeAsV3(writer);
383+
await writer.FlushAsync();
384+
var output = outputStringWriter.ToString();
385+
386+
// Assert: In v3.0, ONLY $ref should appear - no description, no extensions
387+
Assert.Equal(@"{""$ref"":""#/components/schemas/Pet""}", output);
388+
}
389+
390+
[Fact]
391+
public async Task SchemaReferenceExtensionsNotWrittenInV2()
392+
{
393+
// Arrange
394+
var reference = new OpenApiSchemaReference("Pet", null)
395+
{
396+
Description = "Local description",
397+
Extensions = new Dictionary<string, IOpenApiExtension>
398+
{
399+
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
400+
}
401+
};
402+
403+
var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
404+
var writer = new OpenApiJsonWriter(outputStringWriter, new OpenApiJsonWriterSettings { Terse = true });
405+
406+
// Act
407+
reference.SerializeAsV2(writer);
408+
await writer.FlushAsync();
409+
var output = outputStringWriter.ToString();
410+
411+
// Assert: In v2, ONLY $ref should appear - no description, no extensions
412+
Assert.Equal(@"{""$ref"":""#/definitions/Pet""}", output);
413+
}
259414
}
260415
}

0 commit comments

Comments
 (0)