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 94a3e73732a3..b9961e96de26 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 @@ - + + diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index b14ef5fb3d40..91f5660e9051 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 @@ public static class RequestDelegateFactory private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo StringResultWriteResponseAsyncMethod = GetMethodInfo>((response, text) => HttpResponseWritingExtensions.WriteAsync(response, text, default)); private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = GetMethodInfo>((response, value) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, default)); - private static readonly MethodInfo EnumTryParseMethod = GetEnumTryParseMethod(); private static readonly MethodInfo LogParameterBindingFailureMethod = GetMethodInfo>((httpContext, parameterType, parameterName, sourceValue) => Log.ParameterBindingFailed(httpContext, parameterType, parameterName, sourceValue)); @@ -56,8 +53,6 @@ public static class RequestDelegateFactory private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null)); - private static readonly ConcurrentDictionary TryParseMethodCache = new(); - /// /// Creates a implementation for . /// @@ -197,7 +192,7 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext { 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 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext { 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 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, }; } - 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(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 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres 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 bac3c83201dd..8ecbae2c39a6 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -479,7 +479,7 @@ public void CreateThrowsInvalidOperationExceptionGivenUnnamedArgument() var unnamedParameter = Expression.Parameter(typeof(int)); var lambda = Expression.Lambda(Expression.Block(), unnamedParameter); var ex = Assert.Throws(() => RequestDelegateFactory.Create((Action)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 fb538f4d5283..9d8c6a7c33d7 100644 --- a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs @@ -61,7 +61,7 @@ public static MinimalActionEndpointConventionBuilder MapPost( /// The to add the route to. /// The route pattern. /// The delegate executed when the endpoint is matched. - /// A that canaction be used to further customize the endpoint. + /// A that can be used to further customize the endpoint. public static MinimalActionEndpointConventionBuilder MapPut( this IEndpointRouteBuilder endpoints, string pattern, @@ -166,6 +166,12 @@ public static MinimalActionEndpointConventionBuilder Map( 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 fec8da0ce7cc..f43d9d97d55d 100644 --- a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs @@ -42,7 +42,9 @@ void TestAction() 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().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 @@ void TestAction() Assert.Equal("BUILDER", GetMethod(metadataArray[2])); Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata()!.HttpMethods.Single()); - - string GetMethod(object metadata) - { - var httpMethodMetadata = Assert.IsAssignableFrom(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 330ff630384d..fcf7dfcf05ab 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -77,13 +77,48 @@ private ICollection GetApiResponseTypes( IReadOnlyList 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 ReadResponseMetadata( + IReadOnlyList responseMetadataAttributes, + Type? type, + Type defaultErrorType, + MediaTypeCollection contentTypes) { var results = new Dictionary(); // 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 @@ private ICollection GetApiResponseTypes( { // 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 @@ private ICollection GetApiResponseTypes( } } - // 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 responseTypes, MediaTypeCollection declaredContentTypes) diff --git a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs index 843216b21a9b..ea388cdfbb36 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs @@ -429,7 +429,7 @@ private IReadOnlyList GetSupportedFormats(MediaTypeCollection return results; } - private static MediaTypeCollection GetDeclaredContentTypes(IApiRequestMetadataProvider[]? requestMetadataAttributes) + internal static MediaTypeCollection GetDeclaredContentTypes(IReadOnlyList? 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 000000000000..0be4f4c89410 --- /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 +{ + /// + /// Extensions for configuring ApiExplorer using . + /// + public static class EndpointMetadataApiExplorerServiceCollectionExtensions + { + /// + /// Configures ApiExplorer using . + /// + /// The . + public static IServiceCollection AddEndpointsApiExplorer(this IServiceCollection services) + { + // Try to add default services in case MVC services aren't added. + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddEnumerable( + ServiceDescriptor.Transient()); + + 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 000000000000..e4bef2e33fa7 --- /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() is { } methodInfo && + routeEndpoint.Metadata.GetMetadata() 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().FirstOrDefault() is { } routeAttribute) + { + return (BindingSource.Path, routeAttribute.Name ?? parameter.Name ?? string.Empty); + } + else if (attributes.OfType().FirstOrDefault() is { } queryAttribute) + { + return (BindingSource.Query, queryAttribute.Name ?? parameter.Name ?? string.Empty); + } + else if (attributes.OfType().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 supportedRequestFormats, + bool hasJsonBody, + EndpointMetadataCollection endpointMetadata) + { + var requestMetadata = endpointMetadata.GetOrderedMetadata(); + 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 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 could help here. + if (typeof(IResult).IsAssignableFrom(responseType)) + { + responseType = typeof(void); + } + + var responseMetadata = endpointMetadata.GetOrderedMetadata(); + var errorMetadata = endpointMetadata.GetMetadata(); + 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(T). + return new ApiResponseFormat { MediaType = "application/json" }; + } + } + + private static EndpointModelMetadata CreateModelMetadata(Type type) => + new EndpointModelMetadata(ModelMetadataIdentity.ForType(type)); + + private static void AddResponseContentTypes(IList apiResponseFormats, IReadOnlyList 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 000000000000..fc5fc4267632 --- /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 AdditionalValues { get; } = ImmutableDictionary.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>? EnumGroupedDisplayNamesAndValues { get; } + public override IReadOnlyDictionary? 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()); + public override IPropertyFilterProvider? PropertyFilterProvider { get; } + public override Func? PropertyGetter { get; } + public override Action? 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 ValidatorMetadata { get; } = Array.Empty(); + } +} 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 a3ee0007eedc..4b86355d1087 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 @@ false + + + + diff --git a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt index 069adc68ef65..e06fd7f7a795 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.ApiDescriptionGroupCollectionProvider.ApiDescriptionGroupCollectionProvider(Microsoft.AspNetCore.Mvc.Infrastructure.IActionDescriptorCollectionProvider! actionDescriptorCollectionProvider, System.Collections.Generic.IEnumerable! 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! optionsAccessor, Microsoft.AspNetCore.Routing.IInlineConstraintResolver! constraintResolver, Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider! modelMetadataProvider, Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultTypeMapper! mapper, Microsoft.Extensions.Options.IOptions! 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 000000000000..cd39f51ca3b0 --- /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()); + + 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 GetApiDescriptions( + Delegate action, + string pattern = null, + IEnumerable httpMethods = null) + { + var methodInfo = action.Method; + var attributes = methodInfo.GetCustomAttributes(); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var httpMethodMetadata = new HttpMethodMetadata(httpMethods ?? new[] { "GET" }); + var metadataItems = new List(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 3f1a6d737463..dce16986fb56 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 @@ $(DefaultNetCoreTargetFramework) + Preview diff --git a/src/Mvc/Mvc.slnf b/src/Mvc/Mvc.slnf index b6e6c9fbf539..0d133ef4b69f 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 000000000000..ba378a59d08e --- /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 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(string? value, out TEnum result) not found."); + throw new Exception("static bool System.Enum.TryParse(string? value, out TEnum result) not found."); + } + } +}