Skip to content

fix(library): enforce spec-compliant $ref serialization; add Extensions support for schema references in v3.1/v3.2#2782

Open
Copilot wants to merge 6 commits intomainfrom
copilot/fix-openapi-schema-reference-description
Open

fix(library): enforce spec-compliant $ref serialization; add Extensions support for schema references in v3.1/v3.2#2782
Copilot wants to merge 6 commits intomainfrom
copilot/fix-openapi-schema-reference-description

Conversation

Copy link
Contributor

Copilot AI commented Mar 17, 2026

Per OpenAPI spec, sibling keywords alongside $ref are version-dependent. For v2/v3.0 all siblings must be dropped; for v3.1/v3.2 schema refs, peer keywords including extensions are allowed; for v3.1/v3.2 non-schema refs, only summary and description are permitted. This PR addresses the gap in extension support for schema references.

Description

OpenApiSchemaReference correctly drops sibling keywords (description, title, etc.) for v2/v3.0 serialization, but lacked support for serializing/deserializing extension properties (x-*) alongside $ref in v3.1/v3.2, and was missing a SerializeAsV32 override with loop detection.

Type of Change

  • New feature (non-breaking change which adds functionality)

Related Issue(s)

Changes Made

JsonSchemaReference

  • Added Extensions property (IDictionary<string, IOpenApiExtension>?) — serialized only in v3.1/v3.2, silently dropped for v2/v3.0
  • Updated copy constructor to copy extensions
  • SerializeAdditionalV3XProperties now calls writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi3_1)
  • SetAdditional31MetadataFromMapNode now parses x-* properties from $ref sibling keywords into Extensions

OpenApiSchemaReference

  • Extensions upgraded from getter-only (Target?.Extensions) to get/set — reference-level extensions take priority over target
  • Added SerializeAsV32 override with loop detection (matches existing SerializeAsV31 pattern)

PublicAPI.Unshipped.txt

  • Registered: JsonSchemaReference.Extensions (get/set), OpenApiSchemaReference.Extensions.set, OpenApiSchemaReference.SerializeAsV32

Tests

  • Updated SerializeSchemaReferenceAsV31JsonWorks to include an extension; updated verified snapshots
  • Added SerializeSchemaReferenceAsV32JsonWorks + snapshots
  • Added ParseSchemaReferenceWithExtensionsWorks — parses x-* from a v3.1 $ref node
  • Added SchemaReferenceExtensionsNotWrittenInV30 / SchemaReferenceExtensionsNotWrittenInV2 — asserts only $ref is emitted
// v3.1: extensions written alongside $ref
var schemaRef = new OpenApiSchemaReference("Pet", doc)
{
    Description = "Overridden description",
    Extensions = new Dictionary<string, IOpenApiExtension>
    {
        ["x-custom"] = new JsonNodeExtension(JsonValue.Create("value"))
    }
};
// Serializes as: { "description": "...", "x-custom": "value", "$ref": "#/components/schemas/Pet" }

// v3.0: ONLY $ref emitted — description and extensions silently dropped
// { "$ref": "#/components/schemas/Pet" }

Testing

  • Unit tests added/updated
  • All existing tests pass

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Versions applicability

  • My change applies to the version 1.X of the library, if so PR link:
  • My change applies to the version 2.X of the library, if so PR link:
  • My change applies to the version 3.X of the library, if so PR link:
  • I have evaluated the applicability of my change against the other versions above.

Additional Notes

Extension support is intentionally scoped to JsonSchemaReference (schema refs only). Non-schema reference objects (OpenApiReferenceWithDescription, OpenApiReferenceWithDescriptionAndSummary) remain extension-free per the v3.1 Reference Object spec, which explicitly prohibits additional properties.

Original prompt

This section details on the original issue you should resolve

<issue_title>OpenApiSchemaReference allows Description alongside $ref producing non-spec-compliant output in OAS 3.0 (clone of #2745)</issue_title>
<issue_description>## Description

The Microsoft.OpenApi library allows setting Description on OpenApiSchemaReference, which results in serialized output containing both $ref and description.

However, according to OpenAPI 3.0.x specification:

When a $ref is used, all other properties SHALL be ignored.

This makes $ref effectively a replacement, meaning sibling keywords like description should not appear.


Example

Code

using System;
using System.IO;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Writers;

class Program
{
    static void Main()
    {
        var document = new OpenApiDocument
        {
            Info = new OpenApiInfo
            {
                Title = "Ref Description Test",
                Version = "1.0.0"
            },

            Components = new OpenApiComponents
            {
                Schemas =
                {
                    ["Pet"] = new OpenApiSchema
                    {
                        Type = "object",
                        Properties =
                        {
                            ["name"] = new OpenApiSchema { Type = "string" }
                        }
                    }
                }
            },

            Paths = new OpenApiPaths
            {
                ["/test"] = new OpenApiPathItem
                {
                    Operations =
                    {
                        [OperationType.Get] = new OpenApiOperation
                        {
                            Responses =
                            {
                                ["200"] = new OpenApiResponse
                                {
                                    Description = "OK",
                                    Content =
                                    {
                                        ["application/json"] = new OpenApiMediaType
                                        {
                                            Schema = new OpenApiSchemaReference("Pet")
                                            {
                                                Description = "Local description"
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        };

        using var stream = new MemoryStream();
        using var writer = new OpenApiJsonWriter(new StreamWriter(stream));

        document.SerializeAsV3(writer);
        writer.Flush();

        stream.Position = 0;
        Console.WriteLine(new StreamReader(stream).ReadToEnd());
    }
}

Output

$ref: '#/components/schemas/Pet'
description: Local description

Expected behavior

One of:

  1. Serializer wraps in allOf
allOf:
  - $ref: '#/components/schemas/Pet'
description: Local description
  1. Description ignored when targeting OAS 3.0

  2. Validation warning


Notes


Question

What is the intended behavior when serializing references with annotations for OAS 3.0 vs 3.1?
</issue_description>

<agent_instructions>make sure you thoroughly consider my reply to implement the changes and tests</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@baywet Hi @mdaneri,

Thank you for using the SDK and for reaching out.

I think we need to break this down further:

OpenAPI 2 & 3.0

For any kind of reference (schema, path item, etc..), no sibling keyworks (description, summary, anything really) are allowed. We should drop them during serialization. Extension are not allowed either.

Source specification for v2

Source specification for v3

OpenAPI 3.1 & 3.2

For JSON Schema references, any keyword that's defined as a "peer keyword" can appear alongside the $ref property. That's effectively anything that has a setter in the reference type. Extensions are also allowed.

Source for schema object

For any other kind of reference (path item, etc...), ONLY summary and description are allowed alongside ...


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 2 commits March 17, 2026 18:03
Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
…3.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>
Copilot AI changed the title [WIP] Fix OpenApiSchemaReference non-spec-compliance in output fix(library): enforce spec-compliant $ref serialization; add Extensions support for schema references in v3.1/v3.2 Mar 17, 2026
Copilot AI requested a review from baywet March 17, 2026 18:16
baywet added 3 commits March 17, 2026 14:31
Signed-off-by: Vincent Biret <vibiret@microsoft.com>
…ema references

Signed-off-by: Vincent Biret <vibiret@microsoft.com>
@baywet baywet marked this pull request as ready for review March 17, 2026 18:45
@baywet baywet requested a review from a team as a code owner March 17, 2026 18:45
@baywet baywet enabled auto-merge March 17, 2026 18:45
Comment on lines +159 to +167
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());
}
}

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 about 22 hours 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.
@sonarqubecloud
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenApiSchemaReference allows Description alongside $ref producing non-spec-compliant output in OAS 3.0 (clone of #2745)

2 participants