From fd19f92df938c42525c9f3a2f3acd13d7e7d88f2 Mon Sep 17 00:00:00 2001 From: Stephen Halter <halter73@gmail.com> Date: Fri, 18 Jun 2021 12:52:34 -0700 Subject: [PATCH] Add OpenAPI/Swagger support for minimal actions (#33433) --- ...icrosoft.AspNetCore.Http.Extensions.csproj | 3 +- .../src/RequestDelegateFactory.cs | 77 +--- .../test/RequestDelegateFactoryTests.cs | 2 +- ...malActionEndpointRouteBuilderExtensions.cs | 8 +- ...ctionEndpointRouteBuilderExtensionsTest.cs | 10 +- .../src/ApiResponseTypeProvider.cs | 63 +-- .../src/DefaultApiDescriptionProvider.cs | 2 +- ...oApiExplorerServiceCollectionExtensions.cs | 32 ++ .../EndpointMetadataApiDescriptionProvider.cs | 332 +++++++++++++++ .../src/EndpointModelMetadata.cs | 57 +++ ...icrosoft.AspNetCore.Mvc.ApiExplorer.csproj | 4 + .../src/PublicAPI.Unshipped.txt | 2 + ...pointMetadataApiDescriptionProviderTest.cs | 378 ++++++++++++++++++ ...oft.AspNetCore.Mvc.ApiExplorer.Test.csproj | 1 + src/Mvc/Mvc.slnf | 1 + src/Shared/TryParseMethodCache.cs | 85 ++++ 16 files changed, 947 insertions(+), 110 deletions(-) create mode 100644 src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs create mode 100644 src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs create mode 100644 src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs create mode 100644 src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs create mode 100644 src/Shared/TryParseMethodCache.cs 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 94a3e73732a..b9961e96de2 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -12,7 +12,8 @@ <ItemGroup> <Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" /> - <Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" Link="StreamCopyOperationInternal.cs" /> + <Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" /> + <Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" /> </ItemGroup> <ItemGroup> diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index b14ef5fb3d4..91f5660e905 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -2,9 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Linq.Expressions; @@ -34,7 +32,6 @@ namespace Microsoft.AspNetCore.Http private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo StringResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, string, Task>>((response, text) => HttpResponseWritingExtensions.WriteAsync(response, text, default)); private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, object, Task>>((response, value) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, default)); - private static readonly MethodInfo EnumTryParseMethod = GetEnumTryParseMethod(); private static readonly MethodInfo LogParameterBindingFailureMethod = GetMethodInfo<Action<HttpContext, string, string, string>>((httpContext, parameterType, parameterName, sourceValue) => Log.ParameterBindingFailed(httpContext, parameterType, parameterName, sourceValue)); @@ -56,8 +53,6 @@ namespace Microsoft.AspNetCore.Http private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null)); - private static readonly ConcurrentDictionary<Type, MethodInfo?> TryParseMethodCache = new(); - /// <summary> /// Creates a <see cref="RequestDelegate"/> implementation for <paramref name="action"/>. /// </summary> @@ -197,7 +192,7 @@ namespace Microsoft.AspNetCore.Http { if (parameter.Name is null) { - throw new InvalidOperationException("A parameter does not have a name! Was it genererated? All parameters must be named."); + throw new InvalidOperationException("A parameter does not have a name! Was it generated? All parameters must be named."); } var parameterCustomAttributes = parameter.GetCustomAttributes(); @@ -230,7 +225,7 @@ namespace Microsoft.AspNetCore.Http { return RequestAbortedExpr; } - else if (parameter.ParameterType == typeof(string) || HasTryParseMethod(parameter)) + else if (parameter.ParameterType == typeof(string) || TryParseMethodCache.HasTryParseMethod(parameter)) { return BindParameterFromRouteValueOrQueryString(parameter, parameter.Name, factoryContext); } @@ -477,72 +472,6 @@ namespace Microsoft.AspNetCore.Http }; } - private static MethodInfo GetEnumTryParseMethod() - { - var staticEnumMethods = typeof(Enum).GetMethods(BindingFlags.Public | BindingFlags.Static); - - foreach (var method in staticEnumMethods) - { - if (!method.IsGenericMethod || method.Name != "TryParse" || method.ReturnType != typeof(bool)) - { - continue; - } - - var tryParseParameters = method.GetParameters(); - - if (tryParseParameters.Length == 2 && - tryParseParameters[0].ParameterType == typeof(string) && - tryParseParameters[1].IsOut) - { - return method; - } - } - - throw new Exception("static bool System.Enum.TryParse<TEnum>(string? value, out TEnum result) does not exist!!?!?"); - } - - // TODO: Use InvariantCulture where possible? Or is CurrentCulture fine because it's more flexible? - private static MethodInfo? FindTryParseMethod(Type type) - { - static MethodInfo? Finder(Type type) - { - if (type.IsEnum) - { - return EnumTryParseMethod.MakeGenericMethod(type); - } - - var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static); - - foreach (var method in staticMethods) - { - if (method.Name != "TryParse" || method.ReturnType != typeof(bool)) - { - continue; - } - - var tryParseParameters = method.GetParameters(); - - if (tryParseParameters.Length == 2 && - tryParseParameters[0].ParameterType == typeof(string) && - tryParseParameters[1].IsOut && - tryParseParameters[1].ParameterType == type.MakeByRefType()) - { - return method; - } - } - - return null; - } - - return TryParseMethodCache.GetOrAdd(type, Finder); - } - - private static bool HasTryParseMethod(ParameterInfo parameter) - { - var nonNullableParameterType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; - return FindTryParseMethod(nonNullableParameterType) is not null; - } - private static Expression GetValueFromProperty(Expression sourceExpression, string key) { var itemProperty = sourceExpression.Type.GetProperty("Item"); @@ -574,7 +503,7 @@ namespace Microsoft.AspNetCore.Http var isNotNullable = underlyingNullableType is null; var nonNullableParameterType = underlyingNullableType ?? parameter.ParameterType; - var tryParseMethod = FindTryParseMethod(nonNullableParameterType); + var tryParseMethod = TryParseMethodCache.FindTryParseMethod(nonNullableParameterType); if (tryParseMethod is null) { diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index bac3c83201d..8ecbae2c39a 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -479,7 +479,7 @@ namespace Microsoft.AspNetCore.Routing.Internal var unnamedParameter = Expression.Parameter(typeof(int)); var lambda = Expression.Lambda(Expression.Block(), unnamedParameter); var ex = Assert.Throws<InvalidOperationException>(() => RequestDelegateFactory.Create((Action<int>)lambda.Compile(), new EmptyServiceProvdier())); - Assert.Equal("A parameter does not have a name! Was it genererated? All parameters must be named.", ex.Message); + Assert.Equal("A parameter does not have a name! Was it generated? All parameters must be named.", ex.Message); } [Fact] diff --git a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs index fb538f4d528..9d8c6a7c33d 100644 --- a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs @@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Builder /// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param> /// <param name="pattern">The route pattern.</param> /// <param name="action">The delegate executed when the endpoint is matched.</param> - /// <returns>A <see cref="IEndpointConventionBuilder"/> that canaction be used to further customize the endpoint.</returns> + /// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to further customize the endpoint.</returns> public static MinimalActionEndpointConventionBuilder MapPut( this IEndpointRouteBuilder endpoints, string pattern, @@ -166,6 +166,12 @@ namespace Microsoft.AspNetCore.Builder DisplayName = pattern.RawText ?? pattern.DebuggerToString(), }; + // REVIEW: Should we add an IActionMethodMetadata with just MethodInfo on it so we are + // explicit about the MethodInfo representing the "action" and not the RequestDelegate? + + // Add MethodInfo as metadata to assist with OpenAPI generation for the endpoint. + builder.Metadata.Add(action.Method); + // Add delegate attributes as metadata var attributes = action.Method.GetCustomAttributes(); diff --git a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs index fec8da0ce7c..f43d9d97d55 100644 --- a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs @@ -42,7 +42,9 @@ namespace Microsoft.AspNetCore.Builder var dataSource = Assert.Single(builder.DataSources); var endpoint = Assert.Single(dataSource.Endpoints); - var metadataArray = endpoint.Metadata.Where(m => m is not CompilerGeneratedAttribute).ToArray(); + var metadataArray = endpoint.Metadata.OfType<IHttpMethodMetadata>().ToArray(); + + static string GetMethod(IHttpMethodMetadata metadata) => Assert.Single(metadata.HttpMethods); Assert.Equal(3, metadataArray.Length); Assert.Equal("ATTRIBUTE", GetMethod(metadataArray[0])); @@ -50,12 +52,6 @@ namespace Microsoft.AspNetCore.Builder Assert.Equal("BUILDER", GetMethod(metadataArray[2])); Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()!.HttpMethods.Single()); - - string GetMethod(object metadata) - { - var httpMethodMetadata = Assert.IsAssignableFrom<IHttpMethodMetadata>(metadata); - return Assert.Single(httpMethodMetadata.HttpMethods); - } } [Fact] diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 330ff630384..fcf7dfcf05a 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -77,13 +77,48 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes, Type? type, Type defaultErrorType) + { + var contentTypes = new MediaTypeCollection(); + + var responseTypes = ReadResponseMetadata(responseMetadataAttributes, type, defaultErrorType, contentTypes); + + // Set the default status only when no status has already been set explicitly + if (responseTypes.Count == 0 && type != null) + { + responseTypes.Add(new ApiResponseType + { + StatusCode = StatusCodes.Status200OK, + Type = type, + }); + } + + if (contentTypes.Count == 0) + { + // None of the IApiResponseMetadataProvider specified a content type. This is common for actions that + // specify one or more ProducesResponseType but no ProducesAttribute. In this case, formatters will participate in conneg + // and respond to the incoming request. + // Querying IApiResponseTypeMetadataProvider.GetSupportedContentTypes with "null" should retrieve all supported + // content types that each formatter may respond in. + contentTypes.Add((string)null!); + } + + CalculateResponseFormats(responseTypes, contentTypes); + + return responseTypes; + } + + // Shared with EndpointMetadataApiDescriptionProvider + internal static List<ApiResponseType> ReadResponseMetadata( + IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes, + Type? type, + Type defaultErrorType, + MediaTypeCollection contentTypes) { var results = new Dictionary<int, ApiResponseType>(); // Get the content type that the action explicitly set to support. // Walk through all 'filter' attributes in order, and allow each one to see or override // the results of the previous ones. This is similar to the execution path for content-negotiation. - var contentTypes = new MediaTypeCollection(); if (responseMetadataAttributes != null) { foreach (var metadataAttribute in responseMetadataAttributes) @@ -105,7 +140,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { // ProducesResponseTypeAttribute's constructor defaults to setting "Type" to void when no value is specified. // In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a - // [ProducesResponseType(201)] instead of [ProducesResponseType(201, typeof(Person)] when typeof(Person) can be inferred + // [ProducesResponseType(201)] instead of [ProducesResponseType(typeof(Person), 201] when typeof(Person) can be inferred // from the return type. apiResponseType.Type = type; } @@ -129,29 +164,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } } - // Set the default status only when no status has already been set explicitly - if (results.Count == 0 && type != null) - { - results[StatusCodes.Status200OK] = new ApiResponseType - { - StatusCode = StatusCodes.Status200OK, - Type = type, - }; - } - - if (contentTypes.Count == 0) - { - // None of the IApiResponseMetadataProvider specified a content type. This is common for actions that - // specify one or more ProducesResponseType but no ProducesAttribute. In this case, formatters will participate in conneg - // and respond to the incoming request. - // Querying IApiResponseTypeMetadataProvider.GetSupportedContentTypes with "null" should retrieve all supported - // content types that each formatter may respond in. - contentTypes.Add((string)null!); - } - - var responseTypes = results.Values; - CalculateResponseFormats(responseTypes, contentTypes); - return responseTypes; + return results.Values.ToList(); } private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes, MediaTypeCollection declaredContentTypes) diff --git a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs index 843216b21a9..ea388cdfbb3 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs @@ -429,7 +429,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer return results; } - private static MediaTypeCollection GetDeclaredContentTypes(IApiRequestMetadataProvider[]? requestMetadataAttributes) + internal static MediaTypeCollection GetDeclaredContentTypes(IReadOnlyList<IApiRequestMetadataProvider>? requestMetadataAttributes) { // Walk through all 'filter' attributes in order, and allow each one to see or override // the results of the previous ones. This is similar to the execution path for content-negotiation. diff --git a/src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs b/src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs new file mode 100644 index 00000000000..0be4f4c8941 --- /dev/null +++ b/src/Mvc/Mvc.ApiExplorer/src/DependencyInjection/EndpointMethodInfoApiExplorerServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// <summary> + /// Extensions for configuring ApiExplorer using <see cref="Endpoint.Metadata"/>. + /// </summary> + public static class EndpointMetadataApiExplorerServiceCollectionExtensions + { + /// <summary> + /// Configures ApiExplorer using <see cref="Endpoint.Metadata"/>. + /// </summary> + /// <param name="services">The <see cref="IServiceCollection"/>.</param> + public static IServiceCollection AddEndpointsApiExplorer(this IServiceCollection services) + { + // Try to add default services in case MVC services aren't added. + services.TryAddSingleton<IActionDescriptorCollectionProvider, DefaultActionDescriptorCollectionProvider>(); + services.TryAddSingleton<IApiDescriptionGroupCollectionProvider, ApiDescriptionGroupCollectionProvider>(); + + services.TryAddEnumerable( + ServiceDescriptor.Transient<IApiDescriptionProvider, EndpointMetadataApiDescriptionProvider>()); + + return services; + } + } +} diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs new file mode 100644 index 00000000000..e4bef2e33fa --- /dev/null +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -0,0 +1,332 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + internal class EndpointMetadataApiDescriptionProvider : IApiDescriptionProvider + { + private readonly EndpointDataSource _endpointDataSource; + private readonly IHostEnvironment _environment; + private readonly IServiceProviderIsService? _serviceProviderIsService; + + // Executes before MVC's DefaultApiDescriptionProvider and GrpcHttpApiDescriptionProvider for no particular reason. + public int Order => -1100; + + public EndpointMetadataApiDescriptionProvider(EndpointDataSource endpointDataSource, IHostEnvironment environment) + : this(endpointDataSource, environment, null) + { + } + + public EndpointMetadataApiDescriptionProvider( + EndpointDataSource endpointDataSource, + IHostEnvironment environment, + IServiceProviderIsService? serviceProviderIsService) + { + _endpointDataSource = endpointDataSource; + _environment = environment; + _serviceProviderIsService = serviceProviderIsService; + } + + public void OnProvidersExecuting(ApiDescriptionProviderContext context) + { + foreach (var endpoint in _endpointDataSource.Endpoints) + { + if (endpoint is RouteEndpoint routeEndpoint && + routeEndpoint.Metadata.GetMetadata<MethodInfo>() is { } methodInfo && + routeEndpoint.Metadata.GetMetadata<IHttpMethodMetadata>() is { } httpMethodMetadata) + { + // REVIEW: Should we add an ApiDescription for endpoints without IHttpMethodMetadata? Swagger doesn't handle + // a null HttpMethod even though it's nullable on ApiDescription, so we'd need to define "default" HTTP methods. + // In practice, the Delegate will be called for any HTTP method if there is no IHttpMethodMetadata. + foreach (var httpMethod in httpMethodMetadata.HttpMethods) + { + context.Results.Add(CreateApiDescription(routeEndpoint, httpMethod, methodInfo)); + } + } + } + } + + public void OnProvidersExecuted(ApiDescriptionProviderContext context) + { + } + + private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string httpMethod, MethodInfo methodInfo) + { + // Swashbuckle uses the "controller" name to group endpoints together. + // For now, put all methods defined the same declaring type together. + string controllerName; + + if (methodInfo.DeclaringType is not null && !IsCompilerGenerated(methodInfo.DeclaringType)) + { + controllerName = methodInfo.DeclaringType.Name; + } + else + { + // If the declaring type is null or compiler-generated (e.g. lambdas), + // group the methods under the application name. + controllerName = _environment.ApplicationName; + } + + var apiDescription = new ApiDescription + { + HttpMethod = httpMethod, + RelativePath = routeEndpoint.RoutePattern.RawText?.TrimStart('/'), + ActionDescriptor = new ActionDescriptor + { + RouteValues = + { + ["controller"] = controllerName, + }, + }, + }; + + var hasJsonBody = false; + + foreach (var parameter in methodInfo.GetParameters()) + { + var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint.RoutePattern); + + if (parameterDescription.Source == BindingSource.Body) + { + hasJsonBody = true; + } + + apiDescription.ParameterDescriptions.Add(parameterDescription); + } + + AddSupportedRequestFormats(apiDescription.SupportedRequestFormats, hasJsonBody, routeEndpoint.Metadata); + AddSupportedResponseTypes(apiDescription.SupportedResponseTypes, methodInfo.ReturnType, routeEndpoint.Metadata); + + return apiDescription; + } + + private ApiParameterDescription CreateApiParameterDescription(ParameterInfo parameter, RoutePattern pattern) + { + var (source, name) = GetBindingSourceAndName(parameter, pattern); + + return new ApiParameterDescription + { + Name = name, + ModelMetadata = CreateModelMetadata(parameter.ParameterType), + Source = source, + DefaultValue = parameter.DefaultValue, + Type = parameter.ParameterType, + }; + } + + // TODO: Share more of this logic with RequestDelegateFactory.CreateArgument(...) using RequestDelegateFactoryUtilities + // which is shared source. + private (BindingSource, string) GetBindingSourceAndName(ParameterInfo parameter, RoutePattern pattern) + { + var attributes = parameter.GetCustomAttributes(); + + if (attributes.OfType<IFromRouteMetadata>().FirstOrDefault() is { } routeAttribute) + { + return (BindingSource.Path, routeAttribute.Name ?? parameter.Name ?? string.Empty); + } + else if (attributes.OfType<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute) + { + return (BindingSource.Query, queryAttribute.Name ?? parameter.Name ?? string.Empty); + } + else if (attributes.OfType<IFromHeaderMetadata>().FirstOrDefault() is { } headerAttribute) + { + return (BindingSource.Header, headerAttribute.Name ?? parameter.Name ?? string.Empty); + } + else if (parameter.CustomAttributes.Any(a => typeof(IFromBodyMetadata).IsAssignableFrom(a.AttributeType))) + { + return (BindingSource.Body, parameter.Name ?? string.Empty); + } + else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)) || + parameter.ParameterType == typeof(HttpContext) || + parameter.ParameterType == typeof(CancellationToken) || + _serviceProviderIsService?.IsService(parameter.ParameterType) == true) + { + return (BindingSource.Services, parameter.Name ?? string.Empty); + } + else if (parameter.ParameterType == typeof(string) || TryParseMethodCache.HasTryParseMethod(parameter)) + { + // Path vs query cannot be determined by RequestDelegateFactory at startup currently because of the layering, but can be done here. + if (parameter.Name is { } name && pattern.GetParameter(name) is not null) + { + return (BindingSource.Path, name); + } + else + { + return (BindingSource.Query, parameter.Name ?? string.Empty); + } + } + else + { + return (BindingSource.Body, parameter.Name ?? string.Empty); + } + } + + private static void AddSupportedRequestFormats( + IList<ApiRequestFormat> supportedRequestFormats, + bool hasJsonBody, + EndpointMetadataCollection endpointMetadata) + { + var requestMetadata = endpointMetadata.GetOrderedMetadata<IApiRequestMetadataProvider>(); + var declaredContentTypes = DefaultApiDescriptionProvider.GetDeclaredContentTypes(requestMetadata); + + if (declaredContentTypes.Count > 0) + { + foreach (var contentType in declaredContentTypes) + { + supportedRequestFormats.Add(new ApiRequestFormat + { + MediaType = contentType, + }); + } + } + else if (hasJsonBody) + { + supportedRequestFormats.Add(new ApiRequestFormat + { + MediaType = "application/json", + }); + } + } + + private static void AddSupportedResponseTypes( + IList<ApiResponseType> supportedResponseTypes, + Type returnType, + EndpointMetadataCollection endpointMetadata) + { + var responseType = returnType; + + if (AwaitableInfo.IsTypeAwaitable(responseType, out var awaitableInfo)) + { + responseType = awaitableInfo.ResultType; + } + + // Can't determine anything about IResults yet that's not from extra metadata. IResult<T> could help here. + if (typeof(IResult).IsAssignableFrom(responseType)) + { + responseType = typeof(void); + } + + var responseMetadata = endpointMetadata.GetOrderedMetadata<IApiResponseMetadataProvider>(); + var errorMetadata = endpointMetadata.GetMetadata<ProducesErrorResponseTypeAttribute>(); + var defaultErrorType = errorMetadata?.Type ?? typeof(void); + var contentTypes = new MediaTypeCollection(); + + var responseMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata( + responseMetadata, responseType, defaultErrorType, contentTypes); + + if (responseMetadataTypes.Count > 0) + { + foreach (var apiResponseType in responseMetadataTypes) + { + // void means no response type was specified by the metadata, so use whatever we inferred. + // ApiResponseTypeProvider should never return ApiResponseTypes with null Type, but it doesn't hurt to check. + if (apiResponseType.Type is null || apiResponseType.Type == typeof(void)) + { + apiResponseType.Type = responseType; + } + + apiResponseType.ModelMetadata = CreateModelMetadata(apiResponseType.Type); + + if (contentTypes.Count > 0) + { + AddResponseContentTypes(apiResponseType.ApiResponseFormats, contentTypes); + } + else if (CreateDefaultApiResponseFormat(responseType) is { } defaultResponseFormat) + { + apiResponseType.ApiResponseFormats.Add(defaultResponseFormat); + } + + supportedResponseTypes.Add(apiResponseType); + } + } + else + { + // Set the default response type only when none has already been set explicitly with metadata. + var defaultApiResponseType = CreateDefaultApiResponseType(responseType); + + if (contentTypes.Count > 0) + { + // If metadata provided us with response formats, use that instead of the default. + defaultApiResponseType.ApiResponseFormats.Clear(); + AddResponseContentTypes(defaultApiResponseType.ApiResponseFormats, contentTypes); + } + + supportedResponseTypes.Add(defaultApiResponseType); + } + } + + private static ApiResponseType CreateDefaultApiResponseType(Type responseType) + { + var apiResponseType = new ApiResponseType + { + ModelMetadata = CreateModelMetadata(responseType), + StatusCode = 200, + Type = responseType, + }; + + if (CreateDefaultApiResponseFormat(responseType) is { } responseFormat) + { + apiResponseType.ApiResponseFormats.Add(responseFormat); + } + + return apiResponseType; + } + + private static ApiResponseFormat? CreateDefaultApiResponseFormat(Type responseType) + { + if (responseType == typeof(void)) + { + return null; + } + else if (responseType == typeof(string)) + { + // This uses HttpResponse.WriteAsync(string) method which doesn't set a content type. It could be anything, + // but I think "text/plain" is a reasonable assumption if nothing else is specified with metadata. + return new ApiResponseFormat { MediaType = "text/plain" }; + } + else + { + // Everything else is written using HttpResponse.WriteAsJsonAsync<TValue>(T). + return new ApiResponseFormat { MediaType = "application/json" }; + } + } + + private static EndpointModelMetadata CreateModelMetadata(Type type) => + new EndpointModelMetadata(ModelMetadataIdentity.ForType(type)); + + private static void AddResponseContentTypes(IList<ApiResponseFormat> apiResponseFormats, IReadOnlyList<string> contentTypes) + { + foreach (var contentType in contentTypes) + { + apiResponseFormats.Add(new ApiResponseFormat + { + MediaType = contentType, + }); + } + } + + // The CompilerGeneratedAttribute doesn't always get added so we also check if the type name starts with "<" + // For example,w "<>c" is a "declaring" type the C# compiler will generate without the attribute for a top-level lambda + // REVIEW: Is there a better way to do this? + private static bool IsCompilerGenerated(Type type) => + Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute)) || type.Name.StartsWith('<'); + } +} diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs new file mode 100644 index 00000000000..fc5fc426763 --- /dev/null +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointModelMetadata.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + internal class EndpointModelMetadata : ModelMetadata + { + public EndpointModelMetadata(ModelMetadataIdentity identity) : base(identity) + { + IsBindingAllowed = true; + } + + public override IReadOnlyDictionary<object, object> AdditionalValues { get; } = ImmutableDictionary<object, object>.Empty; + public override string? BinderModelName { get; } + public override Type? BinderType { get; } + public override BindingSource? BindingSource { get; } + public override bool ConvertEmptyStringToNull { get; } + public override string? DataTypeName { get; } + public override string? Description { get; } + public override string? DisplayFormatString { get; } + public override string? DisplayName { get; } + public override string? EditFormatString { get; } + public override ModelMetadata? ElementMetadata { get; } + public override IEnumerable<KeyValuePair<EnumGroupAndName, string>>? EnumGroupedDisplayNamesAndValues { get; } + public override IReadOnlyDictionary<string, string>? EnumNamesAndValues { get; } + public override bool HasNonDefaultEditFormat { get; } + public override bool HideSurroundingHtml { get; } + public override bool HtmlEncode { get; } + public override bool IsBindingAllowed { get; } + public override bool IsBindingRequired { get; } + public override bool IsEnum { get; } + public override bool IsFlagsEnum { get; } + public override bool IsReadOnly { get; } + public override bool IsRequired { get; } + public override ModelBindingMessageProvider ModelBindingMessageProvider { get; } = new DefaultModelBindingMessageProvider(); + public override string? NullDisplayText { get; } + public override int Order { get; } + public override string? Placeholder { get; } + public override ModelPropertyCollection Properties { get; } = new(Enumerable.Empty<ModelMetadata>()); + public override IPropertyFilterProvider? PropertyFilterProvider { get; } + public override Func<object, object>? PropertyGetter { get; } + public override Action<object, object?>? PropertySetter { get; } + public override bool ShowForDisplay { get; } + public override bool ShowForEdit { get; } + public override string? SimpleDisplayProperty { get; } + public override string? TemplateHint { get; } + public override bool ValidateChildren { get; } + public override IReadOnlyList<object> ValidatorMetadata { get; } = Array.Empty<object>(); + } +} diff --git a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj index a3ee0007eed..4b86355d108 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj +++ b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj @@ -9,6 +9,10 @@ <IsPackable>false</IsPackable> </PropertyGroup> + <ItemGroup> + <Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" /> + </ItemGroup> + <ItemGroup> <Reference Include="Microsoft.AspNetCore.Mvc.Core" /> </ItemGroup> diff --git a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt index 069adc68ef6..e06fd7f7a79 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt @@ -22,6 +22,8 @@ Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollection.ApiDescriptio Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollection.Items.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroup!>! Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollectionProvider.ApiDescriptionGroupCollectionProvider(Microsoft.AspNetCore.Mvc.Infrastructure.IActionDescriptorCollectionProvider! actionDescriptorCollectionProvider, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionProvider!>! apiDescriptionProviders) -> void Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollectionProvider.ApiDescriptionGroups.get -> Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionGroupCollection! +Microsoft.Extensions.DependencyInjection.EndpointMetadataApiExplorerServiceCollectionExtensions +static Microsoft.Extensions.DependencyInjection.EndpointMetadataApiExplorerServiceCollectionExtensions.AddEndpointsApiExplorer(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! ~Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider.DefaultApiDescriptionProvider(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Mvc.MvcOptions!>! optionsAccessor, Microsoft.AspNetCore.Routing.IInlineConstraintResolver! constraintResolver, Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider! modelMetadataProvider, Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultTypeMapper! mapper, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Routing.RouteOptions!>! routeOptions) -> void Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider.OnProvidersExecuted(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext! context) -> void Microsoft.AspNetCore.Mvc.ApiExplorer.DefaultApiDescriptionProvider.OnProvidersExecuting(Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescriptionProviderContext! context) -> void diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs new file mode 100644 index 00000000000..cd39f51ca3b --- /dev/null +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -0,0 +1,378 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + public class EndpointMetadataApiDescriptionProviderTest + { + [Fact] + public void MultipleApiDescriptionsCreatedForMultipleHttpMethods() + { + var apiDescriptions = GetApiDescriptions(() => { }, "/", new string[] { "FOO", "BAR" }); + + Assert.Equal(2, apiDescriptions.Count); + } + + [Fact] + public void ApiDescriptionNotCreatedIfNoHttpMethods() + { + var apiDescriptions = GetApiDescriptions(() => { }, "/", Array.Empty<string>()); + + Assert.Empty(apiDescriptions); + } + + [Fact] + public void UsesDeclaringTypeAsControllerName() + { + var apiDescription = GetApiDescription(TestAction); + + var declaringTypeName = typeof(EndpointMetadataApiDescriptionProviderTest).Name; + Assert.Equal(declaringTypeName, apiDescription.ActionDescriptor.RouteValues["controller"]); + } + + [Fact] + public void UsesApplicationNameAsControllerNameIfNoDeclaringType() + { + var apiDescription = GetApiDescription(() => { }); + + Assert.Equal(nameof(EndpointMetadataApiDescriptionProviderTest), apiDescription.ActionDescriptor.RouteValues["controller"]); + } + + [Fact] + public void AddsJsonRequestFormatWhenFromBodyInferred() + { + static void AssertJsonRequestFormat(ApiDescription apiDescription) + { + var requestFormat = Assert.Single(apiDescription.SupportedRequestFormats); + Assert.Equal("application/json", requestFormat.MediaType); + Assert.Null(requestFormat.Formatter); + } + + AssertJsonRequestFormat(GetApiDescription( + (InferredJsonClass fromBody) => { })); + + AssertJsonRequestFormat(GetApiDescription( + ([FromBody] int fromBody) => { })); + } + + [Fact] + public void AddsRequestFormatFromMetadata() + { + static void AssertustomRequestFormat(ApiDescription apiDescription) + { + var requestFormat = Assert.Single(apiDescription.SupportedRequestFormats); + Assert.Equal("application/custom", requestFormat.MediaType); + Assert.Null(requestFormat.Formatter); + } + + AssertustomRequestFormat(GetApiDescription( + [Consumes("application/custom")] + (InferredJsonClass fromBody) => { })); + + AssertustomRequestFormat(GetApiDescription( + [Consumes("application/custom")] + ([FromBody] int fromBody) => { })); + } + + [Fact] + public void AddsMultipleRequestFormatsFromMetadata() + { + var apiDescription = GetApiDescription( + [Consumes("application/custom0", "application/custom1")] + (InferredJsonClass fromBody) => { }); + + Assert.Equal(2, apiDescription.SupportedRequestFormats.Count); + + var requestFormat0 = apiDescription.SupportedRequestFormats[0]; + Assert.Equal("application/custom0", requestFormat0.MediaType); + Assert.Null(requestFormat0.Formatter); + + var requestFormat1 = apiDescription.SupportedRequestFormats[1]; + Assert.Equal("application/custom1", requestFormat1.MediaType); + Assert.Null(requestFormat1.Formatter); + } + + [Fact] + public void AddsJsonResponseFormatWhenFromBodyInferred() + { + static void AssertJsonResponse(ApiDescription apiDescription, Type expectedType) + { + var responseType = Assert.Single(apiDescription.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(expectedType, responseType.Type); + Assert.Equal(expectedType, responseType.ModelMetadata.ModelType); + + var responseFormat = Assert.Single(responseType.ApiResponseFormats); + Assert.Equal("application/json", responseFormat.MediaType); + Assert.Null(responseFormat.Formatter); + } + + AssertJsonResponse(GetApiDescription(() => new InferredJsonClass()), typeof(InferredJsonClass)); + AssertJsonResponse(GetApiDescription(() => (IInferredJsonInterface)null), typeof(IInferredJsonInterface)); + } + + [Fact] + public void AddsTextResponseFormatWhenFromBodyInferred() + { + var apiDescription = GetApiDescription(() => "foo"); + + var responseType = Assert.Single(apiDescription.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(string), responseType.Type); + Assert.Equal(typeof(string), responseType.ModelMetadata.ModelType); + + var responseFormat = Assert.Single(responseType.ApiResponseFormats); + Assert.Equal("text/plain", responseFormat.MediaType); + Assert.Null(responseFormat.Formatter); + } + + [Fact] + public void AddsNoResponseFormatWhenItCannotBeInferredAndTheresNoMetadata() + { + static void AssertVoid(ApiDescription apiDescription) + { + var responseType = Assert.Single(apiDescription.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(void), responseType.Type); + Assert.Equal(typeof(void), responseType.ModelMetadata.ModelType); + + Assert.Empty(responseType.ApiResponseFormats); + } + + AssertVoid(GetApiDescription(() => { })); + AssertVoid(GetApiDescription(() => Task.CompletedTask)); + AssertVoid(GetApiDescription(() => new ValueTask())); + } + + [Fact] + public void AddsResponseFormatFromMetadata() + { + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)] + [Produces("application/custom")] + () => new InferredJsonClass()); + + var responseType = Assert.Single(apiDescription.SupportedResponseTypes); + + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(typeof(TimeSpan), responseType.Type); + Assert.Equal(typeof(TimeSpan), responseType.ModelMetadata.ModelType); + + var responseFormat = Assert.Single(responseType.ApiResponseFormats); + Assert.Equal("application/custom", responseFormat.MediaType); + } + + [Fact] + public void AddsMultipleResponseFormatsFromMetadata() + { + var apiDescription = GetApiDescription( + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + () => new InferredJsonClass()); + + Assert.Equal(2, apiDescription.SupportedResponseTypes.Count); + + var createdResponseType = apiDescription.SupportedResponseTypes[0]; + + Assert.Equal(201, createdResponseType.StatusCode); + Assert.Equal(typeof(TimeSpan), createdResponseType.Type); + Assert.Equal(typeof(TimeSpan), createdResponseType.ModelMetadata.ModelType); + + var createdResponseFormat = Assert.Single(createdResponseType.ApiResponseFormats); + Assert.Equal("application/json", createdResponseFormat.MediaType); + + var badRequestResponseType = apiDescription.SupportedResponseTypes[1]; + + Assert.Equal(400, badRequestResponseType.StatusCode); + Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.Type); + Assert.Equal(typeof(InferredJsonClass), badRequestResponseType.ModelMetadata.ModelType); + + var badRequestResponseFormat = Assert.Single(badRequestResponseType.ApiResponseFormats); + Assert.Equal("application/json", badRequestResponseFormat.MediaType); + } + + [Fact] + public void AddsFromRouteParameterAsPath() + { + static void AssertPathParameter(ApiDescription apiDescription) + { + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(typeof(int), param.Type); + Assert.Equal(typeof(int), param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Path, param.Source); + } + + AssertPathParameter(GetApiDescription((int foo) => { }, "/{foo}")); + AssertPathParameter(GetApiDescription(([FromRoute] int foo) => { })); + } + + [Fact] + public void AddsFromQueryParameterAsQuery() + { + static void AssertQueryParameter(ApiDescription apiDescription) + { + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(typeof(int), param.Type); + Assert.Equal(typeof(int), param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Query, param.Source); + } + + AssertQueryParameter(GetApiDescription((int foo) => { }, "/")); + AssertQueryParameter(GetApiDescription(([FromQuery] int foo) => { })); + } + + [Fact] + public void AddsFromHeaderParameterAsHeader() + { + var apiDescription = GetApiDescription(([FromHeader] int foo) => { }); + var param = Assert.Single(apiDescription.ParameterDescriptions); + + Assert.Equal(typeof(int), param.Type); + Assert.Equal(typeof(int), param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Header, param.Source); + } + + [Fact] + public void AddsFromServiceParameterAsService() + { + static void AssertServiceParameter(ApiDescription apiDescription, Type expectedType) + { + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(expectedType, param.Type); + Assert.Equal(expectedType, param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Services, param.Source); + } + + AssertServiceParameter(GetApiDescription((IInferredServiceInterface foo) => { }), typeof(IInferredServiceInterface)); + AssertServiceParameter(GetApiDescription(([FromServices] int foo) => { }), typeof(int)); + AssertServiceParameter(GetApiDescription((HttpContext context) => { }), typeof(HttpContext)); + AssertServiceParameter(GetApiDescription((CancellationToken token) => { }), typeof(CancellationToken)); + } + + [Fact] + public void AddsFromBodyParameterAsBody() + { + static void AssertBodyParameter(ApiDescription apiDescription, Type expectedType) + { + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(expectedType, param.Type); + Assert.Equal(expectedType, param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Body, param.Source); + } + + AssertBodyParameter(GetApiDescription((InferredJsonClass foo) => { }), typeof(InferredJsonClass)); + AssertBodyParameter(GetApiDescription(([FromBody] int foo) => { }), typeof(int)); + } + + [Fact] + public void AddsDefaultValueFromParameters() + { + var apiDescription = GetApiDescription(TestActionWithDefaultValue); + + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(42, param.DefaultValue); + } + + [Fact] + public void AddsMultipleParameters() + { + var apiDescription = GetApiDescription(([FromRoute] int foo, int bar, InferredJsonClass fromBody) => { }); + Assert.Equal(3, apiDescription.ParameterDescriptions.Count); + + var fooParam = apiDescription.ParameterDescriptions[0]; + Assert.Equal(typeof(int), fooParam.Type); + Assert.Equal(typeof(int), fooParam.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Path, fooParam.Source); + + var barParam = apiDescription.ParameterDescriptions[1]; + Assert.Equal(typeof(int), barParam.Type); + Assert.Equal(typeof(int), barParam.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Query, barParam.Source); + + var fromBodyParam = apiDescription.ParameterDescriptions[2]; + Assert.Equal(typeof(InferredJsonClass), fromBodyParam.Type); + Assert.Equal(typeof(InferredJsonClass), fromBodyParam.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Body, fromBodyParam.Source); + } + + private IList<ApiDescription> GetApiDescriptions( + Delegate action, + string pattern = null, + IEnumerable<string> httpMethods = null) + { + var methodInfo = action.Method; + var attributes = methodInfo.GetCustomAttributes(); + var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>()); + + var httpMethodMetadata = new HttpMethodMetadata(httpMethods ?? new[] { "GET" }); + var metadataItems = new List<object>(attributes) { methodInfo, httpMethodMetadata }; + var endpointMetadata = new EndpointMetadataCollection(metadataItems.ToArray()); + var routePattern = RoutePatternFactory.Parse(pattern ?? "/"); + + var endpoint = new RouteEndpoint(httpContext => Task.CompletedTask, routePattern, 0, endpointMetadata, null); + var endpointDataSource = new DefaultEndpointDataSource(endpoint); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + return context.Results; + } + + private ApiDescription GetApiDescription(Delegate action, string pattern = null) => + Assert.Single(GetApiDescriptions(action, pattern)); + + private static void TestAction() + { + } + + private static void TestActionWithDefaultValue(int foo = 42) + { + } + + private class InferredJsonClass + { + } + + private interface IInferredServiceInterface + { + } + + private interface IInferredJsonInterface + { + } + + private class ServiceProviderIsService : IServiceProviderIsService + { + public bool IsService(Type serviceType) => serviceType == typeof(IInferredServiceInterface); + } + + private class HostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } + public string ApplicationName { get; set; } + public string ContentRootPath { get; set; } + public IFileProvider ContentRootFileProvider { get; set; } + } + } +} diff --git a/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj b/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj index 3f1a6d73746..dce16986fb5 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj +++ b/src/Mvc/Mvc.ApiExplorer/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test.csproj @@ -2,6 +2,7 @@ <PropertyGroup> <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework> + <LangVersion>Preview</LangVersion> </PropertyGroup> <ItemGroup> diff --git a/src/Mvc/Mvc.slnf b/src/Mvc/Mvc.slnf index b6e6c9fbf53..0d133ef4b69 100644 --- a/src/Mvc/Mvc.slnf +++ b/src/Mvc/Mvc.slnf @@ -32,6 +32,7 @@ "src\\Http\\Routing.Abstractions\\src\\Microsoft.AspNetCore.Routing.Abstractions.csproj", "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", "src\\Http\\WebUtilities\\src\\Microsoft.AspNetCore.WebUtilities.csproj", + "src\\Http\\samples\\MinimalSample\\MinimalSample.csproj", "src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj", "src\\Localization\\Abstractions\\src\\Microsoft.Extensions.Localization.Abstractions.csproj", "src\\Localization\\Localization\\src\\Microsoft.Extensions.Localization.csproj", diff --git a/src/Shared/TryParseMethodCache.cs b/src/Shared/TryParseMethodCache.cs new file mode 100644 index 00000000000..ba378a59d08 --- /dev/null +++ b/src/Shared/TryParseMethodCache.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; + +namespace Microsoft.AspNetCore.Http +{ + internal static class TryParseMethodCache + { + private static readonly MethodInfo EnumTryParseMethod = GetEnumTryParseMethod(); + + // Since this is shared source, the cache won't be shared between RequestDelegateFactory and the ApiDescriptionProvider sadly :( + private static readonly ConcurrentDictionary<Type, MethodInfo?> Cache = new(); + + public static bool HasTryParseMethod(ParameterInfo parameter) + { + var nonNullableParameterType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + return FindTryParseMethod(nonNullableParameterType) is not null; + } + + // TODO: Use InvariantCulture where possible? Or is CurrentCulture fine because it's more flexible? + public static MethodInfo? FindTryParseMethod(Type type) + { + static MethodInfo? Finder(Type type) + { + if (type.IsEnum) + { + return EnumTryParseMethod.MakeGenericMethod(type); + } + + var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static); + + foreach (var method in staticMethods) + { + if (method.Name != "TryParse" || method.ReturnType != typeof(bool)) + { + continue; + } + + var tryParseParameters = method.GetParameters(); + + if (tryParseParameters.Length == 2 && + tryParseParameters[0].ParameterType == typeof(string) && + tryParseParameters[1].IsOut && + tryParseParameters[1].ParameterType == type.MakeByRefType()) + { + return method; + } + } + + return null; + } + + return Cache.GetOrAdd(type, Finder); + } + + private static MethodInfo GetEnumTryParseMethod() + { + var staticEnumMethods = typeof(Enum).GetMethods(BindingFlags.Public | BindingFlags.Static); + + foreach (var method in staticEnumMethods) + { + if (!method.IsGenericMethod || method.Name != nameof(Enum.TryParse) || method.ReturnType != typeof(bool)) + { + continue; + } + + var tryParseParameters = method.GetParameters(); + + if (tryParseParameters.Length == 2 && + tryParseParameters[0].ParameterType == typeof(string) && + tryParseParameters[1].IsOut) + { + return method; + } + } + + Debug.Fail("static bool System.Enum.TryParse<TEnum>(string? value, out TEnum result) not found."); + throw new Exception("static bool System.Enum.TryParse<TEnum>(string? value, out TEnum result) not found."); + } + } +} -- GitLab