Skip to content
20 changes: 19 additions & 1 deletion src/Microsoft.OpenApi/Models/JsonSchemaReference.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

using System;
Expand Down Expand Up @@ -52,6 +52,12 @@ public class JsonSchemaReference : OpenApiReferenceWithDescription
/// </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 @@ public JsonSchemaReference(JsonSchemaReference reference) : base(reference)
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 @@ private void SerializeAdditionalV3XProperties(IOpenApiWriter writer, Action<IOpe
{
writer.WriteOptionalCollection(OpenApiConstants.Examples, Examples, (w, e) => w.WriteAny(e));
}
writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi3_1);
}

/// <inheritdoc/>
Expand Down Expand Up @@ -146,5 +154,15 @@ protected override void SetAdditional31MetadataFromMapNode(JsonObject jsonObject
{
Examples = examplesArray.OfType<JsonNode>().ToList();
}

// Extensions (properties starting with "x-")
foreach (var property in jsonObject
.Where(static p => p.Key.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase)
&& p.Value is not null))
{
var extensionValue = property.Value!;
Extensions ??= new Dictionary<string, IOpenApiExtension>(StringComparer.OrdinalIgnoreCase);
Extensions[property.Key] = new JsonNodeExtension(extensionValue.DeepClone());
}
}
}
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