Skip to content

Commit 0bb3d68

Browse files
authored
[release/7.0] Infer response metadata in RequestDelegateFactory (#43961)
* Infer response metadata in RequestDelegateFactory * Update OpenApiRouteHandlerBuilderExtensionTests * Infer 200 status despite HttpContext and HttpResponse parameters * Remove dead code
1 parent 9b287dd commit 0bb3d68

6 files changed

+124
-34
lines changed

src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<Description>ASP.NET Core common extension methods for HTTP abstractions, HTTP headers, HTTP request/response, and session state.</Description>
@@ -17,6 +17,7 @@
1717
<Compile Include="$(SharedSourceRoot)EndpointMetadataPopulator.cs" LinkBase="Shared"/>
1818
<Compile Include="$(SharedSourceRoot)PropertyAsParameterInfo.cs" LinkBase="Shared"/>
1919
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" LinkBase="Shared" />
20+
<Compile Include="$(SharedSourceRoot)ApiExplorerTypes\*.cs" LinkBase="Shared" />
2021
<Compile Include="$(SharedSourceRoot)RoutingMetadata\AcceptsMetadata.cs" LinkBase="Shared" />
2122
<Compile Include="$(SharedSourceRoot)TypeNameHelper\TypeNameHelper.cs" LinkBase="Shared"/>
2223
<Compile Include="$(SharedSourceRoot)ProblemDetails\ProblemDetailsDefaults.cs" LinkBase="Shared" />

src/Http/Http.Extensions/src/RequestDelegateFactory.cs

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ public static partial class RequestDelegateFactory
110110
private static readonly MemberExpression FilterContextHttpContextStatusCodeExpr = Expression.Property(FilterContextHttpContextResponseExpr, typeof(HttpResponse).GetProperty(nameof(HttpResponse.StatusCode))!);
111111
private static readonly ParameterExpression InvokedFilterContextExpr = Expression.Parameter(typeof(EndpointFilterInvocationContext), "filterContext");
112112

113-
private static readonly string[] DefaultAcceptsContentType = new[] { "application/json" };
113+
private static readonly string[] DefaultAcceptsAndProducesContentType = new[] { JsonConstants.JsonContentType };
114114
private static readonly string[] FormFileContentType = new[] { "multipart/form-data" };
115+
private static readonly string[] PlaintextContentType = new[] { "text/plain" };
115116

116117
/// <summary>
117118
/// 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
378379

379380
if (!factoryContext.MetadataAlreadyInferred)
380381
{
382+
PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext.EndpointBuilder);
383+
381384
// Add metadata provided by the delegate return type and parameter types next, this will be more specific than inferred metadata from above
382-
EndpointMetadataPopulator.PopulateMetadata(methodInfo,
383-
factoryContext.EndpointBuilder,
384-
factoryContext.Parameters);
385+
EndpointMetadataPopulator.PopulateMetadata(methodInfo, factoryContext.EndpointBuilder, factoryContext.Parameters);
385386
}
386387

387388
return args;
@@ -927,6 +928,47 @@ private static Expression CreateParamCheckingResponseWritingMethodCall(Type retu
927928
return Expression.Block(localVariables, checkParamAndCallMethod);
928929
}
929930

931+
private static void PopulateBuiltInResponseTypeMetadata(Type returnType, EndpointBuilder builder)
932+
{
933+
if (returnType.IsByRefLike)
934+
{
935+
throw GetUnsupportedReturnTypeException(returnType);
936+
}
937+
938+
if (returnType == typeof(Task) || returnType == typeof(ValueTask))
939+
{
940+
returnType = typeof(void);
941+
}
942+
else if (AwaitableInfo.IsTypeAwaitable(returnType, out _))
943+
{
944+
var genericTypeDefinition = returnType.IsGenericType ? returnType.GetGenericTypeDefinition() : null;
945+
946+
if (genericTypeDefinition == typeof(Task<>) || genericTypeDefinition == typeof(ValueTask<>))
947+
{
948+
returnType = returnType.GetGenericArguments()[0];
949+
}
950+
else
951+
{
952+
throw GetUnsupportedReturnTypeException(returnType);
953+
}
954+
}
955+
956+
// Skip void returns and IResults. IResults might implement IEndpointMetadataProvider but otherwise we don't know what it might do.
957+
if (returnType == typeof(void) || typeof(IResult).IsAssignableFrom(returnType))
958+
{
959+
return;
960+
}
961+
962+
if (returnType == typeof(string))
963+
{
964+
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: null, statusCode: 200, PlaintextContentType));
965+
}
966+
else
967+
{
968+
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(returnType, statusCode: 200, DefaultAcceptsAndProducesContentType));
969+
}
970+
}
971+
930972
private static Expression AddResponseWritingToMethodCall(Expression methodCall, Type returnType)
931973
{
932974
// Exact request delegate match
@@ -1021,7 +1063,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall,
10211063
else
10221064
{
10231065
// TODO: Handle custom awaitables
1024-
throw new NotSupportedException($"Unsupported return type: {TypeNameHelper.GetTypeDisplayName(returnType)}");
1066+
throw GetUnsupportedReturnTypeException(returnType);
10251067
}
10261068
}
10271069
else if (typeof(IResult).IsAssignableFrom(returnType))
@@ -1039,8 +1081,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall,
10391081
}
10401082
else if (returnType.IsByRefLike)
10411083
{
1042-
// Unsupported
1043-
throw new NotSupportedException($"Unsupported return type: {TypeNameHelper.GetTypeDisplayName(returnType)}");
1084+
throw GetUnsupportedReturnTypeException(returnType);
10441085
}
10451086
else if (returnType.IsValueType)
10461087
{
@@ -1849,7 +1890,7 @@ private static Expression BindParameterFromBody(ParameterInfo parameter, bool al
18491890

18501891
factoryContext.JsonRequestBodyParameter = parameter;
18511892
factoryContext.AllowEmptyRequestBody = allowEmpty || isOptional;
1852-
AddInferredAcceptsMetadata(factoryContext, parameter.ParameterType, DefaultAcceptsContentType);
1893+
AddInferredAcceptsMetadata(factoryContext, parameter.ParameterType, DefaultAcceptsAndProducesContentType);
18531894

18541895
if (!factoryContext.AllowEmptyRequestBody)
18551896
{
@@ -2152,6 +2193,12 @@ private static async Task ExecuteResultWriteResponse(IResult? result, HttpContex
21522193
{
21532194
await EnsureRequestResultNotNull(result).ExecuteAsync(httpContext);
21542195
}
2196+
2197+
private static NotSupportedException GetUnsupportedReturnTypeException(Type returnType)
2198+
{
2199+
return new NotSupportedException($"Unsupported return type: {TypeNameHelper.GetTypeDisplayName(returnType)}");
2200+
}
2201+
21552202
private static class RequestDelegateFactoryConstants
21562203
{
21572204
public const string RouteAttribute = "Route (Attribute)";

src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
#nullable enable
55

66
using System.Buffers;
7-
using System.Diagnostics.CodeAnalysis;
87
using System.Globalization;
98
using System.IO.Pipelines;
109
using System.Linq.Expressions;
@@ -6033,7 +6032,7 @@ string HelloName()
60336032
public void Create_DoesNotAddDelegateMethodInfo_AsMetadata()
60346033
{
60356034
// Arrange
6036-
var @delegate = () => "Hello";
6035+
var @delegate = () => { };
60376036

60386037
// Act
60396038
var result = RequestDelegateFactory.Create(@delegate);
@@ -6043,6 +6042,30 @@ public void Create_DoesNotAddDelegateMethodInfo_AsMetadata()
60436042
Assert.Empty(result.EndpointMetadata);
60446043
}
60456044

6045+
[Fact]
6046+
public void Create_AddJsonResponseType_AsMetadata()
6047+
{
6048+
var @delegate = () => new object();
6049+
var result = RequestDelegateFactory.Create(@delegate);
6050+
6051+
var responseMetadata = Assert.IsAssignableFrom<IProducesResponseTypeMetadata>(Assert.Single(result.EndpointMetadata));
6052+
6053+
Assert.Equal("application/json", Assert.Single(responseMetadata.ContentTypes));
6054+
Assert.Equal(typeof(object), responseMetadata.Type);
6055+
}
6056+
6057+
[Fact]
6058+
public void Create_AddPlaintextResponseType_AsMetadata()
6059+
{
6060+
var @delegate = () => "Hello";
6061+
var result = RequestDelegateFactory.Create(@delegate);
6062+
6063+
var responseMetadata = Assert.IsAssignableFrom<IProducesResponseTypeMetadata>(Assert.Single(result.EndpointMetadata));
6064+
6065+
Assert.Equal("text/plain", Assert.Single(responseMetadata.ContentTypes));
6066+
Assert.Null(responseMetadata.Type);
6067+
}
6068+
60466069
[Fact]
60476070
public void Create_DoesNotAddAnythingBefore_ThePassedInEndpointMetadata()
60486071
{
@@ -6278,7 +6301,7 @@ public void Create_CombinesPropertiesAsParameterMetadata_AndTopLevelParameter()
62786301
public void Create_CombinesAllMetadata_InCorrectOrder()
62796302
{
62806303
// Arrange
6281-
var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataResult();
6304+
var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataPoco();
62826305
var options = new RequestDelegateFactoryOptions
62836306
{
62846307
EndpointBuilder = CreateEndpointBuilder(new List<object>
@@ -6298,12 +6321,14 @@ public void Create_CombinesAllMetadata_InCorrectOrder()
62986321
m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Caller }),
62996322
// Inferred AcceptsMetadata from RDF for complex type
63006323
m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)),
6324+
// Inferred ProducesResopnseTypeMetadata from RDF for complex type
6325+
m => Assert.Equal(typeof(CountsDefaultEndpointMetadataPoco), ((IProducesResponseTypeMetadata)m).Type),
63016326
// Metadata provided by parameters implementing IEndpointParameterMetadataProvider
63026327
m => Assert.True(m is ParameterNameMetadata { Name: "param1" }),
63036328
// Metadata provided by parameters implementing IEndpointMetadataProvider
63046329
m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }),
63056330
// Metadata provided by return type implementing IEndpointMetadataProvider
6306-
m => Assert.True(m is MetadataCountMetadata { Count: 4 }));
6331+
m => Assert.True(m is MetadataCountMetadata { Count: 5 }));
63076332
}
63086333

63096334
[Fact]
@@ -6369,7 +6394,7 @@ public void Create_DoesNotInferMetadata_GivenManuallyConstructedMetadataResult()
63696394
public void InferMetadata_ThenCreate_CombinesAllMetadata_InCorrectOrder()
63706395
{
63716396
// Arrange
6372-
var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataResult();
6397+
var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataPoco();
63736398
var options = new RequestDelegateFactoryOptions
63746399
{
63756400
EndpointBuilder = CreateEndpointBuilder(),
@@ -6384,12 +6409,14 @@ public void InferMetadata_ThenCreate_CombinesAllMetadata_InCorrectOrder()
63846409
Assert.Collection(result.EndpointMetadata,
63856410
// Inferred AcceptsMetadata from RDF for complex type
63866411
m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)),
6412+
// Inferred ProducesResopnseTypeMetadata from RDF for complex type
6413+
m => Assert.Equal(typeof(CountsDefaultEndpointMetadataPoco), ((IProducesResponseTypeMetadata)m).Type),
63876414
// Metadata provided by parameters implementing IEndpointParameterMetadataProvider
63886415
m => Assert.True(m is ParameterNameMetadata { Name: "param1" }),
63896416
// Metadata provided by parameters implementing IEndpointMetadataProvider
63906417
m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }),
63916418
// Metadata provided by return type implementing IEndpointMetadataProvider
6392-
m => Assert.True(m is MetadataCountMetadata { Count: 3 }),
6419+
m => Assert.True(m is MetadataCountMetadata { Count: 4 }),
63936420
// Entry-specific metadata added after a call to InferMetadata
63946421
m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Caller }));
63956422
}
@@ -6635,6 +6662,15 @@ public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
66356662
public Task ExecuteAsync(HttpContext httpContext) => Task.CompletedTask;
66366663
}
66376664

6665+
private class CountsDefaultEndpointMetadataPoco : IEndpointMetadataProvider
6666+
{
6667+
public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder)
6668+
{
6669+
var currentMetadataCount = builder.Metadata.Count;
6670+
builder.Metadata.Add(new MetadataCountMetadata { Count = currentMetadataCount });
6671+
}
6672+
}
6673+
66386674
private class RemovesAcceptsParameterMetadata : IEndpointParameterMetadataProvider
66396675
{
66406676
public static void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder)

src/OpenApi/src/OpenApiGenerator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointM
193193
foreach (var annotation in eligibileAnnotations)
194194
{
195195
var statusCode = annotation.Key.ToString(CultureInfo.InvariantCulture);
196+
197+
// TODO: Use the discarded response Type for schema generation
196198
var (_, contentTypes) = annotation.Value;
197199
var responseContent = new Dictionary<string, OpenApiMediaType>();
198200

src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,16 @@ public void WithOpenApi_WorksWithMapGroupAndEndpointAnnotations()
136136
var groupDataSource = Assert.Single(builder.DataSources);
137137
var endpoint = Assert.Single(groupDataSource.Endpoints);
138138
var operation = endpoint.Metadata.GetMetadata<OpenApiOperation>();
139+
139140
Assert.NotNull(operation);
140-
Assert.Equal("201", operation.Responses.Keys.SingleOrDefault());
141+
Assert.Equal(2, operation.Responses.Count);
142+
143+
var defaultOperation = operation.Responses["200"];
144+
Assert.True(defaultOperation.Content.ContainsKey("text/plain"));
145+
146+
var annotatedOperation = operation.Responses["201"];
147+
// Produces doesn't special case string??
148+
Assert.True(annotatedOperation.Content.ContainsKey("application/json"));
141149
}
142150

143151
[Fact]

src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System;
54
using System.Linq;
65
using Microsoft.AspNetCore.Http.Metadata;
76
using Microsoft.Net.Http.Headers;
@@ -20,11 +19,11 @@ internal sealed class ProducesResponseTypeMetadata : IProducesResponseTypeMetada
2019
/// </summary>
2120
/// <param name="statusCode">The HTTP response status code.</param>
2221
public ProducesResponseTypeMetadata(int statusCode)
23-
: this(typeof(void), statusCode)
22+
: this(type: null, statusCode, Enumerable.Empty<string>())
2423
{
25-
IsResponseTypeSetByDefault = true;
2624
}
2725

26+
// Only for internal use where validation is unnecessary.
2827
/// <summary>
2928
/// Initializes an instance of <see cref="ProducesResponseTypeMetadata"/>.
3029
/// </summary>
@@ -34,7 +33,6 @@ public ProducesResponseTypeMetadata(Type type, int statusCode)
3433
{
3534
Type = type ?? throw new ArgumentNullException(nameof(type));
3635
StatusCode = statusCode;
37-
IsResponseTypeSetByDefault = false;
3836
_contentTypes = Enumerable.Empty<string>();
3937
}
4038

@@ -54,7 +52,6 @@ public ProducesResponseTypeMetadata(Type type, int statusCode, string contentTyp
5452

5553
Type = type ?? throw new ArgumentNullException(nameof(type));
5654
StatusCode = statusCode;
57-
IsResponseTypeSetByDefault = false;
5855

5956
MediaTypeHeaderValue.Parse(contentType);
6057
for (var i = 0; i < additionalContentTypes.Length; i++)
@@ -65,30 +62,29 @@ public ProducesResponseTypeMetadata(Type type, int statusCode, string contentTyp
6562
_contentTypes = GetContentTypes(contentType, additionalContentTypes);
6663
}
6764

65+
// Only for internal use where validation is unnecessary.
66+
private ProducesResponseTypeMetadata(Type? type, int statusCode, IEnumerable<string> contentTypes)
67+
{
68+
69+
Type = type;
70+
StatusCode = statusCode;
71+
_contentTypes = contentTypes;
72+
}
73+
6874
/// <summary>
6975
/// Gets or sets the type of the value returned by an action.
7076
/// </summary>
71-
public Type Type { get; set; }
77+
public Type? Type { get; set; }
7278

7379
/// <summary>
7480
/// Gets or sets the HTTP status code of the response.
7581
/// </summary>
7682
public int StatusCode { get; set; }
7783

78-
/// <summary>
79-
/// Used to distinguish a `Type` set by default in the constructor versus
80-
/// one provided by the user.
81-
///
82-
/// When <see langword="false"/>, then <see cref="Type"/> is set by user.
83-
///
84-
/// When <see langword="true"/>, then <see cref="Type"/> is set by by
85-
/// default in the constructor
86-
/// </summary>
87-
/// <value></value>
88-
internal bool IsResponseTypeSetByDefault { get; }
89-
9084
public IEnumerable<string> ContentTypes => _contentTypes;
9185

86+
internal static ProducesResponseTypeMetadata CreateUnvalidated(Type? type, int statusCode, IEnumerable<string> contentTypes) => new(type, statusCode, contentTypes);
87+
9288
private static List<string> GetContentTypes(string contentType, string[] additionalContentTypes)
9389
{
9490
var contentTypes = new List<string>(additionalContentTypes.Length + 1);

0 commit comments

Comments
 (0)