-
Notifications
You must be signed in to change notification settings - Fork 10.3k
[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 all 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.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,47 @@ private static Expression CreateParamCheckingResponseWritingMethodCall(Type retu | |||||
return Expression.Block(localVariables, checkParamAndCallMethod); | ||||||
} | ||||||
|
||||||
private static void PopulateBuiltInResponseTypeMetadata(Type returnType, 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
|
||||||
} | ||||||
|
||||||
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 +1063,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 +1081,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) | ||||||
{ | ||||||
|
@@ -1849,7 +1890,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 +2193,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] | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,6 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System; | ||
using System.Linq; | ||
using Microsoft.AspNetCore.Http.Metadata; | ||
using Microsoft.Net.Http.Headers; | ||
|
@@ -20,11 +19,11 @@ internal sealed class ProducesResponseTypeMetadata : IProducesResponseTypeMetada | |
/// </summary> | ||
/// <param name="statusCode">The HTTP response status code.</param> | ||
public ProducesResponseTypeMetadata(int statusCode) | ||
: this(typeof(void), statusCode) | ||
: this(type: null, statusCode, Enumerable.Empty<string>()) | ||
{ | ||
IsResponseTypeSetByDefault = true; | ||
} | ||
|
||
// Only for internal use where validation is unnecessary. | ||
/// <summary> | ||
/// Initializes an instance of <see cref="ProducesResponseTypeMetadata"/>. | ||
/// </summary> | ||
|
@@ -34,7 +33,6 @@ public ProducesResponseTypeMetadata(Type type, int statusCode) | |
{ | ||
Type = type ?? throw new ArgumentNullException(nameof(type)); | ||
StatusCode = statusCode; | ||
IsResponseTypeSetByDefault = false; | ||
_contentTypes = Enumerable.Empty<string>(); | ||
} | ||
|
||
|
@@ -54,7 +52,6 @@ public ProducesResponseTypeMetadata(Type type, int statusCode, string contentTyp | |
|
||
Type = type ?? throw new ArgumentNullException(nameof(type)); | ||
StatusCode = statusCode; | ||
IsResponseTypeSetByDefault = false; | ||
|
||
MediaTypeHeaderValue.Parse(contentType); | ||
for (var i = 0; i < additionalContentTypes.Length; i++) | ||
|
@@ -65,30 +62,29 @@ public ProducesResponseTypeMetadata(Type type, int statusCode, string contentTyp | |
_contentTypes = GetContentTypes(contentType, additionalContentTypes); | ||
} | ||
|
||
// Only for internal use where validation is unnecessary. | ||
private ProducesResponseTypeMetadata(Type? type, int statusCode, IEnumerable<string> contentTypes) | ||
{ | ||
|
||
Type = type; | ||
StatusCode = statusCode; | ||
_contentTypes = contentTypes; | ||
} | ||
|
||
/// <summary> | ||
/// Gets or sets the type of the value returned by an action. | ||
/// </summary> | ||
public Type Type { get; set; } | ||
public Type? Type { get; set; } | ||
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. Not sure making this nullable is the right call here. Can you clarify your reasoning as to why this is? 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. This type is internal. The interface is public, and |
||
|
||
/// <summary> | ||
/// Gets or sets the HTTP status code of the response. | ||
/// </summary> | ||
public int StatusCode { get; set; } | ||
|
||
/// <summary> | ||
/// Used to distinguish a `Type` set by default in the constructor versus | ||
/// one provided by the user. | ||
/// | ||
/// When <see langword="false"/>, then <see cref="Type"/> is set by user. | ||
/// | ||
/// When <see langword="true"/>, then <see cref="Type"/> is set by by | ||
/// default in the constructor | ||
/// </summary> | ||
/// <value></value> | ||
internal bool IsResponseTypeSetByDefault { get; } | ||
|
||
public IEnumerable<string> ContentTypes => _contentTypes; | ||
|
||
internal static ProducesResponseTypeMetadata CreateUnvalidated(Type? type, int statusCode, IEnumerable<string> contentTypes) => new(type, statusCode, contentTypes); | ||
|
||
private static List<string> GetContentTypes(string contentType, string[] additionalContentTypes) | ||
{ | ||
var contentTypes = new List<string>(additionalContentTypes.Length + 1); | ||
|
Uh oh!
There was an error while loading. Please reload this page.