Skip to content

Commit 1d2c6ee

Browse files
committed
Fix mapping for nested schemas and [Produces] attributes in OpenAPI implementation (#57852)
* Fix handling of [Produces] without type on controller * Recursively map sub-schemas to reference cache * Rely on MaxDepth behavior in STJ * Improve nested type test
1 parent de9ba2a commit 1d2c6ee

File tree

9 files changed

+387
-24
lines changed

9 files changed

+387
-24
lines changed

src/OpenApi/src/Services/OpenApiDocumentService.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ private async Task<OpenApiResponse> GetResponseAsync(
382382
.SelectMany(attr => attr.ContentTypes);
383383
foreach (var contentType in explicitContentTypes)
384384
{
385-
response.Content[contentType] = new OpenApiMediaType();
385+
response.Content.TryAdd(contentType, new OpenApiMediaType());
386386
}
387387

388388
return response;

src/OpenApi/src/Services/Schemas/OpenApiSchemaService.cs

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal sealed class OpenApiSchemaService(
3232
IOptionsMonitor<OpenApiOptions> optionsMonitor)
3333
{
3434
private readonly OpenApiSchemaStore _schemaStore = serviceProvider.GetRequiredKeyedService<OpenApiSchemaStore>(documentName);
35+
private readonly OpenApiJsonSchemaContext _jsonSchemaContext = new OpenApiJsonSchemaContext(new(jsonOptions.Value.SerializerOptions));
3536
private readonly JsonSerializerOptions _jsonSerializerOptions = new(jsonOptions.Value.SerializerOptions)
3637
{
3738
// In order to properly handle the `RequiredAttribute` on type properties, add a modifier to support
@@ -135,7 +136,9 @@ internal async Task<OpenApiSchema> GetOrCreateSchemaAsync(Type type, IServicePro
135136
{
136137
schemaAsJsonObject.ApplyParameterInfo(parameterDescription, _jsonSerializerOptions.GetTypeInfo(type));
137138
}
138-
var deserializedSchema = JsonSerializer.Deserialize(schemaAsJsonObject, OpenApiJsonSchemaContext.Default.OpenApiJsonSchema);
139+
// Use _jsonSchemaContext constructed from _jsonSerializerOptions to respect shared config set by end-user,
140+
// particularly in the case of maxDepth.
141+
var deserializedSchema = JsonSerializer.Deserialize(schemaAsJsonObject, _jsonSchemaContext.OpenApiJsonSchema);
139142
Debug.Assert(deserializedSchema != null, "The schema should have been deserialized successfully and materialize a non-null value.");
140143
var schema = deserializedSchema.Schema;
141144
await ApplySchemaTransformersAsync(schema, type, scopedServiceProvider, schemaTransformers, parameterDescription, cancellationToken);

src/OpenApi/src/Services/Schemas/OpenApiSchemaStore.cs

+10-6
Original file line numberDiff line numberDiff line change
@@ -77,34 +77,38 @@ public JsonNode GetOrAdd(OpenApiSchemaKey key, Func<OpenApiSchemaKey, JsonNode>
7777
/// been encountered more than once in the document to avoid unnecessarily capturing unique
7878
/// schemas into the top-level document.
7979
/// </summary>
80+
/// <remarks>
81+
/// We don't do a depth check in the recursion call here since we assume that
82+
/// System.Text.Json has already validate the depth of the schema based on
83+
/// the configured JsonSerializerOptions.MaxDepth value.
84+
/// </remarks>
8085
/// <param name="schema">The <see cref="OpenApiSchema"/> to add to the schemas-with-references cache.</param>
8186
/// <param name="captureSchemaByRef"><see langword="true"/> if schema should always be referenced instead of inlined.</param>
8287
public void PopulateSchemaIntoReferenceCache(OpenApiSchema schema, bool captureSchemaByRef)
8388
{
84-
// Only capture top-level schemas by ref. Nested schemas will follow the
85-
// reference by duplicate rules.
8689
AddOrUpdateSchemaByReference(schema, captureSchemaByRef: captureSchemaByRef);
8790
AddOrUpdateAnyOfSubSchemaByReference(schema);
91+
8892
if (schema.AdditionalProperties is not null)
8993
{
90-
AddOrUpdateSchemaByReference(schema.AdditionalProperties);
94+
PopulateSchemaIntoReferenceCache(schema.AdditionalProperties, captureSchemaByRef);
9195
}
9296
if (schema.Items is not null)
9397
{
94-
AddOrUpdateSchemaByReference(schema.Items);
98+
PopulateSchemaIntoReferenceCache(schema.Items, captureSchemaByRef);
9599
}
96100
if (schema.AllOf is not null)
97101
{
98102
foreach (var allOfSchema in schema.AllOf)
99103
{
100-
AddOrUpdateSchemaByReference(allOfSchema);
104+
PopulateSchemaIntoReferenceCache(allOfSchema, captureSchemaByRef);
101105
}
102106
}
103107
if (schema.Properties is not null)
104108
{
105109
foreach (var property in schema.Properties.Values)
106110
{
107-
AddOrUpdateSchemaByReference(property);
111+
PopulateSchemaIntoReferenceCache(property, captureSchemaByRef);
108112
}
109113
}
110114
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/CreateSchemaReferenceIdTests.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public async Task GeneratesSchemaForPoco_WithSchemaReferenceIdCustomization()
6565

6666
// Act
6767
builder.MapPost("/", (Todo todo) => { });
68-
var options = new OpenApiOptions { CreateSchemaReferenceId = (type) => $"{type.Type.Name}Schema" };
68+
var options = new OpenApiOptions { CreateSchemaReferenceId = (type) => type.Type.Name == "Todo" ? $"{type.Type.Name}Schema" : OpenApiOptions.CreateDefaultSchemaReferenceId(type) };
6969

7070
// Assert
7171
await VerifyOpenApiDocument(builder, options, document =>
@@ -114,7 +114,7 @@ public async Task GeneratesInlineSchemaForPoco_WithCustomNullId()
114114

115115
// Act
116116
builder.MapPost("/", (Todo todo) => { });
117-
var options = new OpenApiOptions { CreateSchemaReferenceId = (type) => type.Type.Name == "Todo" ? null : $"{type.Type.Name}Schema" };
117+
var options = new OpenApiOptions { CreateSchemaReferenceId = (type) => type.Type.Name == "Todo" ? null : OpenApiOptions.CreateDefaultSchemaReferenceId(type) };
118118

119119
// Assert
120120
await VerifyOpenApiDocument(builder, options, document =>

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs

+32-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Reflection;
5-
using System.Runtime.InteropServices;
5+
using System.Text;
66
using Microsoft.AspNetCore.Builder;
77
using Microsoft.AspNetCore.Hosting.Server;
88
using Microsoft.AspNetCore.Hosting.Server.Features;
@@ -12,15 +12,18 @@
1212
using Microsoft.AspNetCore.Mvc.ActionConstraints;
1313
using Microsoft.AspNetCore.Mvc.ApiExplorer;
1414
using Microsoft.AspNetCore.Mvc.Controllers;
15+
using Microsoft.AspNetCore.Mvc.Formatters;
1516
using Microsoft.AspNetCore.Mvc.Infrastructure;
1617
using Microsoft.AspNetCore.Mvc.ModelBinding;
1718
using Microsoft.AspNetCore.OpenApi;
1819
using Microsoft.AspNetCore.Routing;
1920
using Microsoft.AspNetCore.Routing.Constraints;
2021
using Microsoft.Extensions.DependencyInjection;
2122
using Microsoft.Extensions.Options;
23+
using Microsoft.Net.Http.Headers;
2224
using Microsoft.OpenApi.Models;
2325
using Moq;
26+
using Xunit.Sdk;
2427
using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests;
2528

2629
public abstract class OpenApiDocumentServiceTestBase
@@ -53,7 +56,10 @@ internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuild
5356
ApplicationName = nameof(OpenApiDocumentServiceTests)
5457
};
5558

56-
var options = new MvcOptions();
59+
var options = new MvcOptions
60+
{
61+
OutputFormatters = { new TestJsonOutputFormatter() }
62+
};
5763
var optionsAccessor = Options.Create(options);
5864

5965
var constraintResolver = new Mock<IInlineConstraintResolver>();
@@ -87,6 +93,22 @@ internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuild
8793
return documentService;
8894
}
8995

96+
private class TestJsonOutputFormatter : TextOutputFormatter
97+
{
98+
public TestJsonOutputFormatter()
99+
{
100+
SupportedMediaTypes.Add(new MediaTypeHeaderValue("application/json"));
101+
SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/json"));
102+
103+
SupportedEncodings.Add(Encoding.UTF8);
104+
}
105+
106+
public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
107+
{
108+
return Task.FromResult(0);
109+
}
110+
}
111+
90112
internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions)
91113
{
92114
var context = new ApiDescriptionProviderContext([]);
@@ -203,6 +225,14 @@ public ControllerActionDescriptor CreateActionDescriptor(string methodName = nul
203225
action.RouteValues.Add("controller", "Test");
204226
action.RouteValues.Add("action", action.MethodInfo.Name);
205227
action.ActionConstraints = [new HttpMethodActionConstraint(["GET"])];
228+
action.EndpointMetadata = [..action.MethodInfo.GetCustomAttributes()];
229+
if (controllerType is not null)
230+
{
231+
foreach (var attribute in controllerType.GetCustomAttributes())
232+
{
233+
action.EndpointMetadata.Add(attribute);
234+
}
235+
}
206236

207237
action.Parameters = [];
208238
foreach (var parameter in action.MethodInfo.GetParameters())

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiSchemaService/OpenApiSchemaService.ResponseSchemas.cs

+62-8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.ComponentModel;
55
using Microsoft.AspNetCore.Builder;
66
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Mvc;
78
using Microsoft.OpenApi.Any;
89
using Microsoft.OpenApi.Models;
910

@@ -292,8 +293,9 @@ await VerifyOpenApiDocument(builder, document =>
292293
property =>
293294
{
294295
Assert.Equal("value", property.Key);
295-
Assert.Equal("object", property.Value.Type);
296-
Assert.Collection(property.Value.Properties,
296+
var propertyValue = property.Value.GetEffective(document);
297+
Assert.Equal("object", propertyValue.Type);
298+
Assert.Collection(propertyValue.Properties,
297299
property =>
298300
{
299301
Assert.Equal("id", property.Key);
@@ -317,8 +319,9 @@ await VerifyOpenApiDocument(builder, document =>
317319
property =>
318320
{
319321
Assert.Equal("error", property.Key);
320-
Assert.Equal("object", property.Value.Type);
321-
Assert.Collection(property.Value.Properties, property =>
322+
var propertyValue = property.Value.GetEffective(document);
323+
Assert.Equal("object", propertyValue.Type);
324+
Assert.Collection(propertyValue.Properties, property =>
322325
{
323326
Assert.Equal("code", property.Key);
324327
Assert.Equal("integer", property.Value.Type);
@@ -404,8 +407,10 @@ await VerifyOpenApiDocument(builder, document =>
404407
property =>
405408
{
406409
Assert.Equal("todo", property.Key);
407-
Assert.Equal("object", property.Value.Type);
408-
Assert.Collection(property.Value.Properties,
410+
Assert.NotNull(property.Value.Reference);
411+
var propertyValue = property.Value.GetEffective(document);
412+
Assert.Equal("object", propertyValue.Type);
413+
Assert.Collection(propertyValue.Properties,
409414
property =>
410415
{
411416
Assert.Equal("id", property.Key);
@@ -529,8 +534,10 @@ await VerifyOpenApiDocument(builder, document =>
529534
Assert.Equal("items", property.Key);
530535
Assert.Equal("array", property.Value.Type);
531536
Assert.NotNull(property.Value.Items);
532-
Assert.Equal("object", property.Value.Items.Type);
533-
Assert.Collection(property.Value.Items.Properties,
537+
Assert.NotNull(property.Value.Items.Reference);
538+
Assert.Equal("object", property.Value.Items.GetEffective(document).Type);
539+
var itemsValue = property.Value.Items.GetEffective(document);
540+
Assert.Collection(itemsValue.Properties,
534541
property =>
535542
{
536543
Assert.Equal("id", property.Key);
@@ -653,6 +660,53 @@ await VerifyOpenApiDocument(builder, document =>
653660
});
654661
}
655662

663+
[Fact]
664+
public async Task GetOpenApiResponse_SupportsProducesWithProducesResponseTypeOnController()
665+
{
666+
var actionDescriptor = CreateActionDescriptor(nameof(TestController.Get), typeof(TestController));
667+
668+
await VerifyOpenApiDocument(actionDescriptor, document =>
669+
{
670+
var operation = document.Paths["/"].Operations[OperationType.Get];
671+
var responses = Assert.Single(operation.Responses);
672+
var response = responses.Value;
673+
Assert.True(response.Content.TryGetValue("application/json", out var mediaType));
674+
var schema = mediaType.Schema.GetEffective(document);
675+
Assert.Equal("object", schema.Type);
676+
Assert.Collection(schema.Properties,
677+
property =>
678+
{
679+
Assert.Equal("id", property.Key);
680+
Assert.Equal("integer", property.Value.Type);
681+
},
682+
property =>
683+
{
684+
Assert.Equal("title", property.Key);
685+
Assert.Equal("string", property.Value.Type);
686+
},
687+
property =>
688+
{
689+
Assert.Equal("completed", property.Key);
690+
Assert.Equal("boolean", property.Value.Type);
691+
},
692+
property =>
693+
{
694+
Assert.Equal("createdAt", property.Key);
695+
Assert.Equal("string", property.Value.Type);
696+
Assert.Equal("date-time", property.Value.Format);
697+
});
698+
});
699+
}
700+
701+
[ApiController]
702+
[Produces("application/json")]
703+
public class TestController
704+
{
705+
[Route("/")]
706+
[ProducesResponseType(typeof(Todo), StatusCodes.Status200OK)]
707+
internal Todo Get() => new(1, "Write test", false, DateTime.Now);
708+
}
709+
656710
private class ClassWithObjectProperty
657711
{
658712
public object Object { get; set; }

0 commit comments

Comments
 (0)