Skip to content

Commit e4ca1fd

Browse files
captainsafiaxC0dex
andauthored
Fix up OpenAPI schema handling and support concurrent requests (#58024) (#58096)
* Fix JsonUnmappedMemberHandling attribute handling to close #57981 * Fix enum handling for MVC actions to close #57979 * Fix self-referential schema handling to close #58006 * Fix concurrent request handling for OpenAPI documents (#57972) * fix: Allow concurrent requests * test: Update test * test: Use Parallel.ForEachAsync * feat: Use valueFactory overload * feat: Pass valueFactory directly * Harden self-referencing schema ID check --------- Co-authored-by: Justin Lampe <[email protected]>
1 parent 745868d commit e4ca1fd

File tree

7 files changed

+216
-1
lines changed

7 files changed

+216
-1
lines changed

src/OpenApi/src/Schemas/OpenApiJsonSchema.Helpers.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@ public static void ReadProperty(ref Utf8JsonReader reader, string propertyName,
280280
break;
281281
case OpenApiSchemaKeywords.AdditionalPropertiesKeyword:
282282
reader.Read();
283+
if (reader.TokenType == JsonTokenType.False)
284+
{
285+
schema.AdditionalPropertiesAllowed = false;
286+
break;
287+
}
283288
var additionalPropsConverter = (JsonConverter<OpenApiJsonSchema>)options.GetTypeInfo(typeof(OpenApiJsonSchema)).Converter;
284289
schema.AdditionalProperties = additionalPropsConverter.Read(ref reader, typeof(OpenApiJsonSchema), options)?.Schema;
285290
break;

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,12 @@ private async Task<OpenApiResponse> GetResponseAsync(
403403
continue;
404404
}
405405

406+
// MVC's ModelMetadata layer will set ApiParameterDescription.Type to string when the parameter
407+
// is a parsable or convertible type. In this case, we want to use the actual model type
408+
// to generate the schema instead of the string type.
409+
var targetType = parameter.Type == typeof(string) && parameter.ModelMetadata.ModelType != parameter.Type
410+
? parameter.ModelMetadata.ModelType
411+
: parameter.Type;
406412
var openApiParameter = new OpenApiParameter
407413
{
408414
Name = parameter.Name,
@@ -414,7 +420,7 @@ private async Task<OpenApiResponse> GetResponseAsync(
414420
_ => throw new InvalidOperationException($"Unsupported parameter source: {parameter.Source.Id}")
415421
},
416422
Required = IsRequired(parameter),
417-
Schema = await _componentService.GetOrCreateSchemaAsync(parameter.Type, scopedServiceProvider, schemaTransformers, parameter, cancellationToken: cancellationToken),
423+
Schema = await _componentService.GetOrCreateSchemaAsync(targetType, scopedServiceProvider, schemaTransformers, parameter, cancellationToken: cancellationToken),
418424
Description = GetParameterDescriptionFromAttribute(parameter)
419425
};
420426

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ private void AddOrUpdateAnyOfSubSchemaByReference(OpenApiSchema schema)
154154
private void AddOrUpdateSchemaByReference(OpenApiSchema schema, string? baseTypeSchemaId = null, bool captureSchemaByRef = false)
155155
{
156156
var targetReferenceId = baseTypeSchemaId is not null ? $"{baseTypeSchemaId}{GetSchemaReferenceId(schema)}" : GetSchemaReferenceId(schema);
157+
// Schemas that already have a reference provided by JsonSchemaExporter are skipped here
158+
// and handled by the OpenApiSchemaReferenceTransformer instead. This case typically kicks
159+
// in for self-referencing schemas where JsonSchemaExporter inlines references to avoid
160+
// infinite recursion.
161+
if (schema.Reference is not null)
162+
{
163+
return;
164+
}
157165
if (SchemasByReference.TryGetValue(schema, out var referenceId) || captureSchemaByRef)
158166
{
159167
// If we've already used this reference ID else where in the document, increment a counter value to the reference

src/OpenApi/src/Transformers/Implementations/OpenApiSchemaReferenceTransformer.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC
102102
return new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = referenceId } };
103103
}
104104

105+
// Handle schemas where the references have been inline by the JsonSchemaExporter. In this case,
106+
// the `#` ID is generated by the exporter since it has no base document to baseline against. In this
107+
// case we we want to replace the reference ID with the schema ID that was generated by the
108+
// `CreateSchemaReferenceId` method in the OpenApiSchemaService.
109+
if (!isTopLevel && schema.Reference is { Type: ReferenceType.Schema, Id: "#" }
110+
&& schema.Annotations.TryGetValue(OpenApiConstants.SchemaId, out var schemaId))
111+
{
112+
return new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = schemaId?.ToString() } };
113+
}
114+
105115
if (schema.AllOf is not null)
106116
{
107117
for (var i = 0; i < schema.AllOf.Count; i++)

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,59 @@ await VerifyOpenApiDocument(builder, document =>
537537
Assert.Null(operation.RequestBody.Content["application/json"].Schema.Type);
538538
});
539539
}
540+
541+
[Theory]
542+
[InlineData(false)]
543+
[InlineData(true)]
544+
public async Task SupportsParameterWithEnumType(bool useAction)
545+
{
546+
// Arrange
547+
if (!useAction)
548+
{
549+
var builder = CreateBuilder();
550+
builder.MapGet("/api/with-enum", (ItemStatus status) => status);
551+
}
552+
else
553+
{
554+
var action = CreateActionDescriptor(nameof(GetItemStatus));
555+
await VerifyOpenApiDocument(action, AssertOpenApiDocument);
556+
}
557+
558+
static void AssertOpenApiDocument(OpenApiDocument document)
559+
{
560+
var operation = document.Paths["/api/with-enum"].Operations[OperationType.Get];
561+
var parameter = Assert.Single(operation.Parameters);
562+
var response = Assert.Single(operation.Responses).Value.Content["application/json"].Schema;
563+
Assert.NotNull(parameter.Schema.Reference);
564+
Assert.Equal(parameter.Schema.Reference.Id, response.Reference.Id);
565+
var schema = parameter.Schema.GetEffective(document);
566+
Assert.Collection(schema.Enum,
567+
value =>
568+
{
569+
var openApiString = Assert.IsType<OpenApiString>(value);
570+
Assert.Equal("Pending", openApiString.Value);
571+
},
572+
value =>
573+
{
574+
var openApiString = Assert.IsType<OpenApiString>(value);
575+
Assert.Equal("Approved", openApiString.Value);
576+
},
577+
value =>
578+
{
579+
var openApiString = Assert.IsType<OpenApiString>(value);
580+
Assert.Equal("Rejected", openApiString.Value);
581+
});
582+
}
583+
}
584+
585+
[Route("/api/with-enum")]
586+
private ItemStatus GetItemStatus([FromQuery] ItemStatus status) => status;
587+
588+
[JsonConverter(typeof(JsonStringEnumConverter<ItemStatus>))]
589+
internal enum ItemStatus
590+
{
591+
Pending = 0,
592+
Approved = 1,
593+
Rejected = 2,
594+
}
540595
}

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.ComponentModel;
55
using System.ComponentModel.DataAnnotations;
66
using System.IO.Pipelines;
7+
using System.Text.Json.Serialization;
78
using System.Text.Json.Serialization.Metadata;
89
using Microsoft.AspNetCore.Builder;
910
using Microsoft.AspNetCore.Mvc;
@@ -594,4 +595,107 @@ await VerifyOpenApiDocument(builder, document =>
594595
});
595596
});
596597
}
598+
599+
[Fact]
600+
public async Task SupportsClassWithJsonUnmappedMemberHandlingDisallowed()
601+
{
602+
// Arrange
603+
var builder = CreateBuilder();
604+
605+
// Act
606+
builder.MapPost("/api", (ExampleWithDisallowedUnmappedMembers type) => { });
607+
608+
// Assert
609+
await VerifyOpenApiDocument(builder, document =>
610+
{
611+
var operation = document.Paths["/api"].Operations[OperationType.Post];
612+
var requestBody = operation.RequestBody;
613+
var content = Assert.Single(requestBody.Content);
614+
var schema = content.Value.Schema.GetEffective(document);
615+
Assert.Collection(schema.Properties,
616+
property =>
617+
{
618+
Assert.Equal("number", property.Key);
619+
Assert.Equal("integer", property.Value.Type);
620+
});
621+
Assert.False(schema.AdditionalPropertiesAllowed);
622+
});
623+
}
624+
625+
[Fact]
626+
public async Task SupportsClassWithJsonUnmappedMemberHandlingSkipped()
627+
{
628+
// Arrange
629+
var builder = CreateBuilder();
630+
631+
// Act
632+
builder.MapPost("/api", (ExampleWithSkippedUnmappedMembers type) => { });
633+
634+
// Assert
635+
await VerifyOpenApiDocument(builder, document =>
636+
{
637+
var operation = document.Paths["/api"].Operations[OperationType.Post];
638+
var requestBody = operation.RequestBody;
639+
var content = Assert.Single(requestBody.Content);
640+
var schema = content.Value.Schema.GetEffective(document);
641+
Assert.Collection(schema.Properties,
642+
property =>
643+
{
644+
Assert.Equal("number", property.Key);
645+
Assert.Equal("integer", property.Value.Type);
646+
});
647+
Assert.True(schema.AdditionalPropertiesAllowed);
648+
});
649+
}
650+
651+
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
652+
private class ExampleWithDisallowedUnmappedMembers
653+
{
654+
public int Number { get; init; }
655+
}
656+
657+
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Skip)]
658+
private class ExampleWithSkippedUnmappedMembers
659+
{
660+
public int Number { get; init; }
661+
}
662+
663+
[Fact]
664+
public async Task SupportsTypesWithSelfReferencedProperties()
665+
{
666+
// Arrange
667+
var builder = CreateBuilder();
668+
669+
// Act
670+
builder.MapPost("/api", (Parent parent) => { });
671+
672+
// Assert
673+
await VerifyOpenApiDocument(builder, document =>
674+
{
675+
var operation = document.Paths["/api"].Operations[OperationType.Post];
676+
var requestBody = operation.RequestBody;
677+
var content = Assert.Single(requestBody.Content);
678+
var schema = content.Value.Schema.GetEffective(document);
679+
Assert.Collection(schema.Properties,
680+
property =>
681+
{
682+
Assert.Equal("selfReferenceList", property.Key);
683+
Assert.Equal("array", property.Value.Type);
684+
Assert.Equal("Parent", property.Value.Items.Reference.Id);
685+
},
686+
property =>
687+
{
688+
Assert.Equal("selfReferenceDictionary", property.Key);
689+
Assert.Equal("object", property.Value.Type);
690+
Assert.Equal("Parent", property.Value.AdditionalProperties.Reference.Id);
691+
});
692+
});
693+
}
694+
695+
public class Parent
696+
{
697+
public IEnumerable<Parent> SelfReferenceList { get; set; } = [ ];
698+
public IDictionary<string, Parent> SelfReferenceDictionary { get; set; } = new Dictionary<string, Parent>();
699+
}
700+
597701
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Transformers/Implementations/OpenApiSchemaReferenceTransformerTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,4 +450,31 @@ await VerifyOpenApiDocument(builder, document =>
450450
}
451451
});
452452
}
453+
454+
[Fact]
455+
public async Task SelfReferenceMapperOnlyOperatesOnSchemaReferenceTypes()
456+
{
457+
var builder = CreateBuilder();
458+
459+
builder.MapGet("/todo", () => new Todo(1, "Item1", false, DateTime.Now));
460+
461+
var options = new OpenApiOptions();
462+
options.AddSchemaTransformer((schema, context, cancellationToken) =>
463+
{
464+
if (context.JsonTypeInfo.Type == typeof(Todo))
465+
{
466+
schema.Reference = new OpenApiReference { Id = "#", Type = ReferenceType.Link };
467+
}
468+
return Task.CompletedTask;
469+
});
470+
471+
await VerifyOpenApiDocument(builder, options, document =>
472+
{
473+
var operation = document.Paths["/todo"].Operations[OperationType.Get];
474+
var response = operation.Responses["200"].Content["application/json"];
475+
var responseSchema = response.Schema;
476+
Assert.Equal("#", responseSchema.Reference.Id);
477+
Assert.Equal(ReferenceType.Link, responseSchema.Reference.Type);
478+
});
479+
}
453480
}

0 commit comments

Comments
 (0)