Skip to content

Commit 3bcef1a

Browse files
authored
[release/9.0] Fix ModelMetadata for TryParse-parameters in ApiExplorer (#58372)
* Fix ModelMetadata for TryParse-parameters in ApiExplorer (#58350) * Fix ModelMetadata for TryParse-parameters in ApiExplorer * Add tests and update code comments * Update src/OpenApi/src/Services/OpenApiDocumentService.cs * Remove duplicate ambient route param test
1 parent 228b9ea commit 3bcef1a

File tree

2 files changed

+100
-21
lines changed

2 files changed

+100
-21
lines changed

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -403,16 +403,6 @@ 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;
412-
// If the type is null, then we're dealing with an inert
413-
// route parameter that does not define a specific parameter type in the
414-
// route handler or in the response. In this case, we default to a string schema.
415-
targetType ??= typeof(string);
416406
var openApiParameter = new OpenApiParameter
417407
{
418408
Name = parameter.Name,
@@ -424,7 +414,7 @@ private async Task<OpenApiResponse> GetResponseAsync(
424414
_ => throw new InvalidOperationException($"Unsupported parameter source: {parameter.Source.Id}")
425415
},
426416
Required = IsRequired(parameter),
427-
Schema = await _componentService.GetOrCreateSchemaAsync(targetType, scopedServiceProvider, schemaTransformers, parameter, cancellationToken: cancellationToken),
417+
Schema = await _componentService.GetOrCreateSchemaAsync(GetTargetType(description, parameter), scopedServiceProvider, schemaTransformers, parameter, cancellationToken: cancellationToken),
428418
Description = GetParameterDescriptionFromAttribute(parameter)
429419
};
430420

@@ -675,4 +665,41 @@ private async Task<OpenApiRequestBody> GetJsonRequestBody(
675665

676666
return requestBody;
677667
}
668+
669+
/// <remarks>
670+
/// This method is used to determine the target type for a given parameter. The target type
671+
/// is the actual type that should be used to generate the schema for the parameter. This is
672+
/// necessary because MVC's ModelMetadata layer will set ApiParameterDescription.Type to string
673+
/// when the parameter is a parsable or convertible type. In this case, we want to use the actual
674+
/// model type to generate the schema instead of the string type.
675+
/// </remarks>
676+
/// <remarks>
677+
/// This method will also check if no target type was resolved from the <see cref="ApiParameterDescription"/>
678+
/// and default to a string schema. This will happen if we are dealing with an inert route parameter
679+
/// that does not define a specific parameter type in the route handler or in the response.
680+
/// </remarks>
681+
private static Type GetTargetType(ApiDescription description, ApiParameterDescription parameter)
682+
{
683+
var bindingMetadata = description.ActionDescriptor.EndpointMetadata
684+
.OfType<IParameterBindingMetadata>()
685+
.SingleOrDefault(metadata => metadata.Name == parameter.Name);
686+
var parameterType = parameter.Type is not null
687+
? Nullable.GetUnderlyingType(parameter.Type) ?? parameter.Type
688+
: parameter.Type;
689+
690+
// parameter.Type = typeof(string)
691+
// parameter.ModelMetadata.Type = typeof(TEnum)
692+
var requiresModelMetadataFallbackForEnum = parameterType == typeof(string)
693+
&& parameter.ModelMetadata.ModelType != parameter.Type
694+
&& parameter.ModelMetadata.ModelType.IsEnum;
695+
// Enums are exempt because we want to set the OpenApiSchema.Enum field when feasible.
696+
// parameter.Type = typeof(TEnum), typeof(TypeWithTryParse)
697+
// parameter.ModelMetadata.Type = typeof(string)
698+
var hasTryParse = bindingMetadata?.HasTryParse == true && parameterType is not null && !parameterType.IsEnum;
699+
var targetType = requiresModelMetadataFallbackForEnum || hasTryParse
700+
? parameter.ModelMetadata.ModelType
701+
: parameter.Type;
702+
targetType ??= typeof(string);
703+
return targetType;
704+
}
678705
}

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

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,8 @@ public async Task SupportsParameterWithEnumType(bool useAction)
547547
if (!useAction)
548548
{
549549
var builder = CreateBuilder();
550-
builder.MapGet("/api/with-enum", (ItemStatus status) => status);
550+
builder.MapGet("/api/with-enum", (Status status) => status);
551+
await VerifyOpenApiDocument(builder, AssertOpenApiDocument);
551552
}
552553
else
553554
{
@@ -583,15 +584,7 @@ static void AssertOpenApiDocument(OpenApiDocument document)
583584
}
584585

585586
[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-
}
587+
private Status GetItemStatus([FromQuery] Status status) => status;
595588

596589
[Fact]
597590
public async Task SupportsMvcActionWithAmbientRouteParameter()
@@ -610,4 +603,63 @@ await VerifyOpenApiDocument(action, document =>
610603

611604
[Route("/api/with-ambient-route-param/{versionId}")]
612605
private void AmbientRouteParameter() { }
606+
607+
[Theory]
608+
[InlineData(false)]
609+
[InlineData(true)]
610+
public async Task SupportsRouteParameterWithCustomTryParse(bool useAction)
611+
{
612+
// Arrange
613+
var builder = CreateBuilder();
614+
615+
// Act
616+
if (!useAction)
617+
{
618+
builder.MapGet("/api/{student}", (Student student) => student);
619+
await VerifyOpenApiDocument(builder, AssertOpenApiDocument);
620+
}
621+
else
622+
{
623+
var action = CreateActionDescriptor(nameof(GetStudent));
624+
await VerifyOpenApiDocument(action, AssertOpenApiDocument);
625+
}
626+
627+
// Assert
628+
static void AssertOpenApiDocument(OpenApiDocument document)
629+
{
630+
// Parameter is a plain-old string when it comes from the route or query
631+
var operation = document.Paths["/api/{student}"].Operations[OperationType.Get];
632+
var parameter = Assert.Single(operation.Parameters);
633+
Assert.Equal("string", parameter.Schema.Type);
634+
635+
// Type is fully serialized in the response
636+
var response = Assert.Single(operation.Responses).Value;
637+
Assert.True(response.Content.TryGetValue("application/json", out var mediaType));
638+
var schema = mediaType.Schema.GetEffective(document);
639+
Assert.Equal("object", schema.Type);
640+
Assert.Collection(schema.Properties, property =>
641+
{
642+
Assert.Equal("name", property.Key);
643+
Assert.Equal("string", property.Value.Type);
644+
});
645+
}
646+
}
647+
648+
[Route("/api/{student}")]
649+
private Student GetStudent(Student student) => student;
650+
651+
public record Student(string Name)
652+
{
653+
public static bool TryParse(string value, out Student result)
654+
{
655+
if (value is null)
656+
{
657+
result = null;
658+
return false;
659+
}
660+
661+
result = new Student(value);
662+
return true;
663+
}
664+
}
613665
}

0 commit comments

Comments
 (0)