Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
/// </summary>
public IList<JsonNode>? Examples { get; set; }

/// <summary>
/// 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.
/// </summary>
public IDictionary<string, IOpenApiExtension>? Extensions { get; set; }

/// <summary>
/// Parameterless constructor
/// </summary>
Expand All @@ -69,6 +75,7 @@
ReadOnly = reference.ReadOnly;
WriteOnly = reference.WriteOnly;
Examples = reference.Examples;
Extensions = reference.Extensions != null ? new Dictionary<string, IOpenApiExtension>(reference.Extensions) : null;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -106,6 +113,7 @@
{
writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (w, e) => w.WriteAny(e));
}
writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi3_1);
}

/// <inheritdoc/>
Expand Down Expand Up @@ -146,5 +154,16 @@
{
Examples = examplesArray.OfType<JsonNode>().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<string, IOpenApiExtension>(StringComparer.OrdinalIgnoreCase);
Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone());
}
}
Comment on lines +159 to +167

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

Copilot Autofix

AI 3 days ago

In general, to fix this kind of issue you replace a foreach over the entire collection plus an inner conditional that skips most elements with a foreach whose source sequence has already been filtered using .Where(...). The condition in the if moves into the Where predicate. If there are additional conditions on the element (e.g., type checks), those also belong in the predicate when possible.

For this specific case in JsonSchemaReference.SetAdditional31MetadataFromMapNode, we should change the foreach (var property in jsonObject) loop to iterate over jsonObject.Where(...), with the predicate matching the original if condition:

property.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)
    && property.Value is JsonNode extensionValue

Inside the loop we still need access to extensionValue for constructing the JsonNodeExtension. Because the pattern-matching is expression that declares extensionValue cannot be lifted directly into the Where (without losing its scope), we can either (1) re-check/cast inside the loop or (2) use a slightly different LINQ pattern that projects the value. To keep the code minimal and not introduce new types, the least invasive fix is:

  • Use Where only for the key prefix filter and keep the is JsonNode extensionValue pattern check inside the loop.
  • This still reduces nesting and makes the intent clearer while preserving behavior and avoiding more complex LINQ expressions.

Concretely:

  • Modify lines 159–167 to iterate over jsonObject.Where(property => property.Key.StartsWith(...)).
  • Keep the is JsonNode extensionValue pattern inside the loop body exactly as before.
  • No new imports or helpers are required; System.Linq is already imported at the top of the file.
Suggested changeset 1
src/Microsoft.OpenApi/Models/JsonSchemaReference.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
--- a/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
+++ b/src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
@@ -156,10 +156,9 @@
         }
 
         // Extensions (properties starting with "x-")
-        foreach (var property in jsonObject)
+        foreach (var property in jsonObject.Where(p => p.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)))
         {
-            if (property.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)
-                && property.Value is JsonNode extensionValue)
+            if (property.Value is JsonNode extensionValue)
             {
                 Extensions ??= new Dictionary<string, IOpenApiExtension>(StringComparer.OrdinalIgnoreCase);
                 Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone());
@@ -167,3 +165,4 @@
         }
     }
 }
+
EOF
@@ -156,10 +156,9 @@
}

// Extensions (properties starting with "x-")
foreach (var property in jsonObject)
foreach (var property in jsonObject.Where(p => p.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)))
{
if (property.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)
&& property.Value is JsonNode extensionValue)
if (property.Value is JsonNode extensionValue)
{
Extensions ??= new Dictionary<string, IOpenApiExtension>(StringComparer.OrdinalIgnoreCase);
Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone());
@@ -167,3 +165,4 @@
}
}
}

Copilot is powered by AI and may make mistakes. Always verify output.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Microsoft.OpenApi
/// <summary>
/// Schema reference object
/// </summary>
public class OpenApiSchemaReference : BaseOpenApiReferenceHolder<OpenApiSchema, IOpenApiSchema, JsonSchemaReference>, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties
public class OpenApiSchemaReference : BaseOpenApiReferenceHolder<OpenApiSchema, IOpenApiSchema, JsonSchemaReference>, IOpenApiSchema, IOpenApiSchemaWithUnevaluatedProperties, IOpenApiExtensible
{

/// <summary>
Expand Down Expand Up @@ -158,7 +158,11 @@ public bool Deprecated
/// <inheritdoc/>
public OpenApiXml? Xml { get => Target?.Xml; }
/// <inheritdoc/>
public IDictionary<string, IOpenApiExtension>? Extensions { get => Target?.Extensions; }
public IDictionary<string, IOpenApiExtension>? Extensions
{
get => Reference.Extensions ?? Target?.Extensions;
set => Reference.Extensions = value;
}

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

/// <inheritdoc/>
public override void SerializeAsV32(IOpenApiWriter writer)
{
SerializeAsWithoutLoops(writer, (w, element) => (element is IOpenApiSchema s ? CopyReferenceAsTargetElementWithOverrides(s) : element).SerializeAsV32(w));
}

/// <inheritdoc/>
public override void SerializeAsV3(IOpenApiWriter writer)
{
Expand Down
4 changes: 4 additions & 0 deletions src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
#nullable enable
Microsoft.OpenApi.JsonSchemaReference.Extensions.get -> System.Collections.Generic.IDictionary<string!, Microsoft.OpenApi.IOpenApiExtension!>?
Microsoft.OpenApi.JsonSchemaReference.Extensions.set -> void
Microsoft.OpenApi.OpenApiSchemaReference.Extensions.set -> void
override Microsoft.OpenApi.OpenApiSchemaReference.SerializeAsV32(Microsoft.OpenApi.IOpenApiWriter! writer) -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"$ref": "#/definitions/Pet"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"$ref":"#/definitions/Pet"}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
"examples": [
"reference example"
],
"x-custom": "custom value",
"$ref": "#/components/schemas/Pet"
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"description":"Reference Description","default":"reference default","title":"Reference Title","deprecated":true,"readOnly":true,"examples":["reference example"],"$ref":"#/components/schemas/Pet"}
{"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 number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ public async Task SerializeSchemaReferenceAsV31JsonWorks(bool produceTerseOutput
WriteOnly = false,
Deprecated = true,
Default = JsonValue.Create("reference default"),
Examples = new List<JsonNode> { JsonValue.Create("reference example") }
Examples = new List<JsonNode> { JsonValue.Create("reference example") },
Extensions = new Dictionary<string, IOpenApiExtension>
{
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
}
};

var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
Expand All @@ -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)
Expand All @@ -161,7 +165,43 @@ public async Task SerializeSchemaReferenceAsV3JsonWorks(bool produceTerseOutput)
WriteOnly = false,
Deprecated = true,
Default = JsonValue.Create("reference default"),
Examples = new List<JsonNode> { JsonValue.Create("reference example") }
Examples = new List<JsonNode> { JsonValue.Create("reference example") },
Extensions = new Dictionary<string, IOpenApiExtension>
{
["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<JsonNode> { JsonValue.Create("reference example") },
Extensions = new Dictionary<string, IOpenApiExtension>
{
["x-custom"] = new JsonNodeExtension(JsonValue.Create("custom value"))
}
};

var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture);
Expand All @@ -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<JsonNode> { JsonValue.Create("reference example") },
Extensions = new Dictionary<string, IOpenApiExtension>
{
["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()
{
Expand Down Expand Up @@ -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<OpenApiSchemaReference>(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<string, IOpenApiExtension>
{
["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<string, IOpenApiExtension>
{
["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);
}
}
}
Loading