diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index d0041e24175d..d18ed5c70cb3 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core common extension methods for HTTP abstractions, HTTP headers, HTTP request/response, and session state. @@ -17,6 +17,7 @@ + diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index 30db3740bb81..f2de17c13380 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -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" }; /// /// Returns metadata inferred automatically for the created by . @@ -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; + } + + if (returnType == typeof(string)) + { + builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: null, statusCode: 200, PlaintextContentType)); + } + 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)"; diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 8960560ae5d6..c55e8f0de861 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -4,7 +4,6 @@ #nullable enable using System.Buffers; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Pipelines; using System.Linq.Expressions; @@ -6033,7 +6032,7 @@ string HelloName() public void Create_DoesNotAddDelegateMethodInfo_AsMetadata() { // Arrange - var @delegate = () => "Hello"; + var @delegate = () => { }; // Act var result = RequestDelegateFactory.Create(@delegate); @@ -6043,6 +6042,30 @@ public void Create_DoesNotAddDelegateMethodInfo_AsMetadata() Assert.Empty(result.EndpointMetadata); } + [Fact] + public void Create_AddJsonResponseType_AsMetadata() + { + var @delegate = () => new object(); + var result = RequestDelegateFactory.Create(@delegate); + + var responseMetadata = Assert.IsAssignableFrom(Assert.Single(result.EndpointMetadata)); + + Assert.Equal("application/json", Assert.Single(responseMetadata.ContentTypes)); + Assert.Equal(typeof(object), responseMetadata.Type); + } + + [Fact] + public void Create_AddPlaintextResponseType_AsMetadata() + { + var @delegate = () => "Hello"; + var result = RequestDelegateFactory.Create(@delegate); + + var responseMetadata = Assert.IsAssignableFrom(Assert.Single(result.EndpointMetadata)); + + Assert.Equal("text/plain", Assert.Single(responseMetadata.ContentTypes)); + Assert.Null(responseMetadata.Type); + } + [Fact] public void Create_DoesNotAddAnythingBefore_ThePassedInEndpointMetadata() { @@ -6278,7 +6301,7 @@ public void Create_CombinesPropertiesAsParameterMetadata_AndTopLevelParameter() public void Create_CombinesAllMetadata_InCorrectOrder() { // Arrange - var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataResult(); + var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataPoco(); var options = new RequestDelegateFactoryOptions { EndpointBuilder = CreateEndpointBuilder(new List @@ -6298,12 +6321,14 @@ public void Create_CombinesAllMetadata_InCorrectOrder() m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Caller }), // Inferred AcceptsMetadata from RDF for complex type m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)), + // Inferred ProducesResopnseTypeMetadata from RDF for complex type + m => Assert.Equal(typeof(CountsDefaultEndpointMetadataPoco), ((IProducesResponseTypeMetadata)m).Type), // Metadata provided by parameters implementing IEndpointParameterMetadataProvider m => Assert.True(m is ParameterNameMetadata { Name: "param1" }), // Metadata provided by parameters implementing IEndpointMetadataProvider m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }), // Metadata provided by return type implementing IEndpointMetadataProvider - m => Assert.True(m is MetadataCountMetadata { Count: 4 })); + m => Assert.True(m is MetadataCountMetadata { Count: 5 })); } [Fact] @@ -6369,7 +6394,7 @@ public void Create_DoesNotInferMetadata_GivenManuallyConstructedMetadataResult() public void InferMetadata_ThenCreate_CombinesAllMetadata_InCorrectOrder() { // Arrange - var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataResult(); + var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataPoco(); var options = new RequestDelegateFactoryOptions { EndpointBuilder = CreateEndpointBuilder(), @@ -6384,12 +6409,14 @@ public void InferMetadata_ThenCreate_CombinesAllMetadata_InCorrectOrder() Assert.Collection(result.EndpointMetadata, // Inferred AcceptsMetadata from RDF for complex type m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)), + // Inferred ProducesResopnseTypeMetadata from RDF for complex type + m => Assert.Equal(typeof(CountsDefaultEndpointMetadataPoco), ((IProducesResponseTypeMetadata)m).Type), // Metadata provided by parameters implementing IEndpointParameterMetadataProvider m => Assert.True(m is ParameterNameMetadata { Name: "param1" }), // Metadata provided by parameters implementing IEndpointMetadataProvider m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }), // Metadata provided by return type implementing IEndpointMetadataProvider - m => Assert.True(m is MetadataCountMetadata { Count: 3 }), + m => Assert.True(m is MetadataCountMetadata { Count: 4 }), // Entry-specific metadata added after a call to InferMetadata m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Caller })); } @@ -6635,6 +6662,15 @@ public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) public Task ExecuteAsync(HttpContext httpContext) => Task.CompletedTask; } + private class CountsDefaultEndpointMetadataPoco : IEndpointMetadataProvider + { + public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + var currentMetadataCount = builder.Metadata.Count; + builder.Metadata.Add(new MetadataCountMetadata { Count = currentMetadataCount }); + } + } + private class RemovesAcceptsParameterMetadata : IEndpointParameterMetadataProvider { public static void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/OpenApiGenerator.cs index 5d527443e8fb..f6ceaf5ce2a0 100644 --- a/src/OpenApi/src/OpenApiGenerator.cs +++ b/src/OpenApi/src/OpenApiGenerator.cs @@ -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 var (_, contentTypes) = annotation.Value; var responseContent = new Dictionary(); diff --git a/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs b/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs index df983faea609..2a86cb016c64 100644 --- a/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs +++ b/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs @@ -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(); + 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?? + Assert.True(annotatedOperation.Content.ContainsKey("application/json")); } [Fact] diff --git a/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs b/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs index 11b2de096f35..f6a65de95a28 100644 --- a/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs +++ b/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs @@ -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 /// /// The HTTP response status code. public ProducesResponseTypeMetadata(int statusCode) - : this(typeof(void), statusCode) + : this(type: null, statusCode, Enumerable.Empty()) { - IsResponseTypeSetByDefault = true; } + // Only for internal use where validation is unnecessary. /// /// Initializes an instance of . /// @@ -34,7 +33,6 @@ public ProducesResponseTypeMetadata(Type type, int statusCode) { Type = type ?? throw new ArgumentNullException(nameof(type)); StatusCode = statusCode; - IsResponseTypeSetByDefault = false; _contentTypes = Enumerable.Empty(); } @@ -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 contentTypes) + { + + Type = type; + StatusCode = statusCode; + _contentTypes = contentTypes; + } + /// /// Gets or sets the type of the value returned by an action. /// - public Type Type { get; set; } + public Type? Type { get; set; } /// /// Gets or sets the HTTP status code of the response. /// public int StatusCode { get; set; } - /// - /// Used to distinguish a `Type` set by default in the constructor versus - /// one provided by the user. - /// - /// When , then is set by user. - /// - /// When , then is set by by - /// default in the constructor - /// - /// - internal bool IsResponseTypeSetByDefault { get; } - public IEnumerable ContentTypes => _contentTypes; + internal static ProducesResponseTypeMetadata CreateUnvalidated(Type? type, int statusCode, IEnumerable contentTypes) => new(type, statusCode, contentTypes); + private static List GetContentTypes(string contentType, string[] additionalContentTypes) { var contentTypes = new List(additionalContentTypes.Length + 1);