-
Notifications
You must be signed in to change notification settings - Fork 10.4k
[release/7.0] Infer response metadata in RequestDelegateFactory #43961
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -110,8 +110,9 @@ public static partial class RequestDelegateFactory | |||||
private static readonly MemberExpression FilterContextHttpContextStatusCodeExpr = Expression.Property(FilterContextHttpContextResponseExpr, typeof(HttpResponse).GetProperty(nameof(HttpResponse.StatusCode))!); | ||||||
private static readonly ParameterExpression InvokedFilterContextExpr = Expression.Parameter(typeof(EndpointFilterInvocationContext), "filterContext"); | ||||||
|
||||||
private static readonly string[] DefaultAcceptsContentType = new[] { "application/json" }; | ||||||
private static readonly string[] DefaultAcceptsAndProducesContentType = new[] { JsonConstants.JsonContentType }; | ||||||
private static readonly string[] FormFileContentType = new[] { "multipart/form-data" }; | ||||||
private static readonly string[] PlaintextContentType = new[] { "text/plain" }; | ||||||
|
||||||
/// <summary> | ||||||
/// Returns metadata inferred automatically for the <see cref="RequestDelegate"/> created by <see cref="Create(Delegate, RequestDelegateFactoryOptions?, RequestDelegateMetadataResult?)"/>. | ||||||
|
@@ -378,10 +379,10 @@ private static Expression[] CreateArgumentsAndInferMetadata(MethodInfo methodInf | |||||
|
||||||
if (!factoryContext.MetadataAlreadyInferred) | ||||||
{ | ||||||
PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext.Parameters, factoryContext.EndpointBuilder); | ||||||
|
||||||
// Add metadata provided by the delegate return type and parameter types next, this will be more specific than inferred metadata from above | ||||||
EndpointMetadataPopulator.PopulateMetadata(methodInfo, | ||||||
factoryContext.EndpointBuilder, | ||||||
factoryContext.Parameters); | ||||||
EndpointMetadataPopulator.PopulateMetadata(methodInfo, factoryContext.EndpointBuilder, factoryContext.Parameters); | ||||||
} | ||||||
|
||||||
return args; | ||||||
|
@@ -927,6 +928,56 @@ private static Expression CreateParamCheckingResponseWritingMethodCall(Type retu | |||||
return Expression.Block(localVariables, checkParamAndCallMethod); | ||||||
} | ||||||
|
||||||
private static void PopulateBuiltInResponseTypeMetadata(Type returnType, IEnumerable<ParameterInfo> parameters, EndpointBuilder builder) | ||||||
{ | ||||||
if (returnType.IsByRefLike) | ||||||
{ | ||||||
throw GetUnsupportedReturnTypeException(returnType); | ||||||
} | ||||||
|
||||||
if (returnType == typeof(Task) || returnType == typeof(ValueTask)) | ||||||
{ | ||||||
returnType = typeof(void); | ||||||
} | ||||||
else if (AwaitableInfo.IsTypeAwaitable(returnType, out _)) | ||||||
{ | ||||||
var genericTypeDefinition = returnType.IsGenericType ? returnType.GetGenericTypeDefinition() : null; | ||||||
|
||||||
if (genericTypeDefinition == typeof(Task<>) || genericTypeDefinition == typeof(ValueTask<>)) | ||||||
{ | ||||||
returnType = returnType.GetGenericArguments()[0]; | ||||||
} | ||||||
else | ||||||
{ | ||||||
throw GetUnsupportedReturnTypeException(returnType); | ||||||
} | ||||||
} | ||||||
|
||||||
// Skip void returns and IResults. IResults might implement IEndpointMetadataProvider but otherwise we don't know what it might do. | ||||||
if (returnType == typeof(void) || typeof(IResult).IsAssignableFrom(returnType)) | ||||||
{ | ||||||
return; | ||||||
halter73 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
} | ||||||
|
||||||
// Skip methods that have HttpContext or HttpResponse parameters that might change the status code from 200. | ||||||
foreach (var parameter in parameters) | ||||||
{ | ||||||
if (parameter.ParameterType == typeof(HttpContext) || parameter.ParameterType == typeof(HttpResponse)) | ||||||
halter73 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
{ | ||||||
return; | ||||||
} | ||||||
} | ||||||
|
||||||
if (returnType == typeof(string)) | ||||||
{ | ||||||
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: null, statusCode: 200, PlaintextContentType)); | ||||||
halter73 marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Why There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I left a brief comment here but I immediately resolved it. Basically, the caller shouldn't care if internally we used a |
||||||
} | ||||||
else | ||||||
{ | ||||||
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, DefaultAcceptsAndProducesContentType)); | ||||||
} | ||||||
} | ||||||
|
||||||
private static Expression AddResponseWritingToMethodCall(Expression methodCall, Type returnType) | ||||||
{ | ||||||
// Exact request delegate match | ||||||
|
@@ -1021,7 +1072,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, | |||||
else | ||||||
{ | ||||||
// TODO: Handle custom awaitables | ||||||
throw new NotSupportedException($"Unsupported return type: {TypeNameHelper.GetTypeDisplayName(returnType)}"); | ||||||
throw GetUnsupportedReturnTypeException(returnType); | ||||||
} | ||||||
} | ||||||
else if (typeof(IResult).IsAssignableFrom(returnType)) | ||||||
|
@@ -1039,8 +1090,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, | |||||
} | ||||||
else if (returnType.IsByRefLike) | ||||||
{ | ||||||
// Unsupported | ||||||
throw new NotSupportedException($"Unsupported return type: {TypeNameHelper.GetTypeDisplayName(returnType)}"); | ||||||
throw GetUnsupportedReturnTypeException(returnType); | ||||||
} | ||||||
else if (returnType.IsValueType) | ||||||
{ | ||||||
|
@@ -1771,6 +1821,17 @@ private static Expression BindParameterFromBindAsync(ParameterInfo parameter, Re | |||||
return Expression.Convert(boundValueExpr, parameter.ParameterType); | ||||||
} | ||||||
|
||||||
private static void AddInferredProducesResponseTypeMetadata(RequestDelegateFactoryContext factoryContext, Type type, string[] contentTypes) | ||||||
{ | ||||||
if (factoryContext.MetadataAlreadyInferred) | ||||||
{ | ||||||
return; | ||||||
} | ||||||
|
||||||
// Type cannot be null, and contentTypes is either [ "application/json" ] or [ "text/plain" ] both of which are valid. | ||||||
factoryContext.EndpointBuilder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type, statusCode: 200, contentTypes)); | ||||||
} | ||||||
|
||||||
private static void AddInferredAcceptsMetadata(RequestDelegateFactoryContext factoryContext, Type type, string[] contentTypes) | ||||||
{ | ||||||
if (factoryContext.MetadataAlreadyInferred) | ||||||
|
@@ -1849,7 +1910,7 @@ private static Expression BindParameterFromBody(ParameterInfo parameter, bool al | |||||
|
||||||
factoryContext.JsonRequestBodyParameter = parameter; | ||||||
factoryContext.AllowEmptyRequestBody = allowEmpty || isOptional; | ||||||
AddInferredAcceptsMetadata(factoryContext, parameter.ParameterType, DefaultAcceptsContentType); | ||||||
AddInferredAcceptsMetadata(factoryContext, parameter.ParameterType, DefaultAcceptsAndProducesContentType); | ||||||
|
||||||
if (!factoryContext.AllowEmptyRequestBody) | ||||||
{ | ||||||
|
@@ -2152,6 +2213,12 @@ private static async Task ExecuteResultWriteResponse(IResult? result, HttpContex | |||||
{ | ||||||
await EnsureRequestResultNotNull(result).ExecuteAsync(httpContext); | ||||||
} | ||||||
|
||||||
private static NotSupportedException GetUnsupportedReturnTypeException(Type returnType) | ||||||
{ | ||||||
return new NotSupportedException($"Unsupported return type: {TypeNameHelper.GetTypeDisplayName(returnType)}"); | ||||||
} | ||||||
|
||||||
private static class RequestDelegateFactoryConstants | ||||||
{ | ||||||
public const string RouteAttribute = "Route (Attribute)"; | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -193,6 +193,8 @@ private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointM | |
foreach (var annotation in eligibileAnnotations) | ||
{ | ||
var statusCode = annotation.Key.ToString(CultureInfo.InvariantCulture); | ||
|
||
// TODO: Use the discarded response Type for schema generation | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Super nit: we don't need to have this in code since it's tracked in the issue locker and that's a lot more resilient than in-code TODOs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm glad there's an issue. I agree that's more important, but I think the comment is also fine. I don't want to rekick the build just for this at least. |
||
var (_, contentTypes) = annotation.Value; | ||
var responseContent = new Dictionary<string, OpenApiMediaType>(); | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -136,8 +136,16 @@ public void WithOpenApi_WorksWithMapGroupAndEndpointAnnotations() | |
var groupDataSource = Assert.Single(builder.DataSources); | ||
var endpoint = Assert.Single(groupDataSource.Endpoints); | ||
var operation = endpoint.Metadata.GetMetadata<OpenApiOperation>(); | ||
|
||
Assert.NotNull(operation); | ||
Assert.Equal("201", operation.Responses.Keys.SingleOrDefault()); | ||
Assert.Equal(2, operation.Responses.Count); | ||
|
||
var defaultOperation = operation.Responses["200"]; | ||
Assert.True(defaultOperation.Content.ContainsKey("text/plain")); | ||
|
||
var annotatedOperation = operation.Responses["201"]; | ||
// Produces doesn't special case string?? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you mean by this comment? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was just surprised when updating this test that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait it does? That seems like a bug. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, this has been behavior for a while. The We had some extensive conversations about this when we were building out the defaults and decided that setting the default to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #43979. If we agree it's important, I'll try to get a PR out by tomorrow before branching. I'm also going to open an API proposal about making |
||
Assert.True(annotatedOperation.Content.ContainsKey("application/json")); | ||
} | ||
|
||
[Fact] | ||
|
Uh oh!
There was an error while loading. Please reload this page.