Skip to content

Commit 4accf27

Browse files
committed
Fix ModelMetadata for TryParse-parameters in ApiExplorer
1 parent 80ac2ff commit 4accf27

File tree

2 files changed

+81
-21
lines changed

2 files changed

+81
-21
lines changed

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 31 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,34 @@ 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+
var requiresModelMetadataFallback = parameterType == typeof(string) && parameter.ModelMetadata.ModelType != parameter.Type;
690+
// Enums are exempt because we want to set the OpenApiSchema.Enum field when feasible.
691+
var hasTryParse = bindingMetadata?.HasTryParse == true && parameterType is not null && !parameterType.IsEnum;
692+
var targetType = requiresModelMetadataFallback || hasTryParse
693+
? parameter.ModelMetadata.ModelType
694+
: parameter.Type;
695+
targetType ??= typeof(string);
696+
return targetType;
697+
}
678698
}

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

Lines changed: 50 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,51 @@ await VerifyOpenApiDocument(action, document =>
610603

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

0 commit comments

Comments
 (0)