diff --git a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs index 02addb8e468b..15281a28ccb3 100644 --- a/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs @@ -5,9 +5,11 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.CodeAnalysis.CSharp.Symbols; namespace Microsoft.AspNetCore.Builder { @@ -107,7 +109,8 @@ public static MinimalActionEndpointConventionBuilder MapMethods( } var builder = endpoints.Map(RoutePatternFactory.Parse(pattern), action); - builder.WithDisplayName($"{pattern} HTTP: {string.Join(", ", httpMethods)}"); + // Prepends the HTTP method to the DisplayName produced with pattern + method name + builder.Add(b => b.DisplayName = $"HTTP: {string.Join(", ", httpMethods)} {b.DisplayName}"); builder.WithMetadata(new HttpMethodMetadata(httpMethods)); return builder; } @@ -184,6 +187,19 @@ public static MinimalActionEndpointConventionBuilder Map( // Add MethodInfo as metadata to assist with OpenAPI generation for the endpoint. builder.Metadata.Add(action.Method); + // Methods defined in a top-level program are generated as statics so the delegate + // target will be null. Inline lambdas are compiler generated method so they can + // be filtered that way. + if (GeneratedNameParser.TryParseLocalFunctionName(action.Method.Name, out var endpointName) + || !TypeHelper.IsCompilerGeneratedMethod(action.Method)) + { + endpointName ??= action.Method.Name; + + builder.Metadata.Add(new EndpointNameMetadata(endpointName)); + builder.Metadata.Add(new RouteNameMetadata(endpointName)); + builder.DisplayName = $"{builder.DisplayName} => {endpointName}"; + } + // Add delegate attributes as metadata var attributes = action.Method.GetCustomAttributes(); diff --git a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj index 4c0c0a1a5443..e92de9551933 100644 --- a/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj +++ b/src/Http/Routing/src/Microsoft.AspNetCore.Routing.csproj @@ -24,6 +24,8 @@ Microsoft.AspNetCore.Routing.RouteCollection + + diff --git a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs index 4dd5bbaa4569..2d1edcbd7094 100644 --- a/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/MinimalActionEndpointRouteBuilderExtensionsTest.cs @@ -99,7 +99,7 @@ public void MapGet_BuildsEndpointWithCorrectMethod() Assert.Equal("GET", method); var routeEndpointBuilder = GetRouteEndpointBuilder(builder); - Assert.Equal("/ HTTP: GET", routeEndpointBuilder.DisplayName); + Assert.Equal("HTTP: GET /", routeEndpointBuilder.DisplayName); Assert.Equal("/", routeEndpointBuilder.RoutePattern.RawText); } @@ -125,7 +125,7 @@ public async Task MapGetWithRouteParameter_BuildsEndpointWithRouteSpecificBindin Assert.Equal("GET", method); var routeEndpointBuilder = GetRouteEndpointBuilder(builder); - Assert.Equal("/{id} HTTP: GET", routeEndpointBuilder.DisplayName); + Assert.Equal("HTTP: GET /{id}", routeEndpointBuilder.DisplayName); Assert.Equal("/{id}", routeEndpointBuilder.RoutePattern.RawText); // Assert that we don't fallback to the query string @@ -163,7 +163,7 @@ public async Task MapGetWithoutRouteParameter_BuildsEndpointWithQuerySpecificBin Assert.Equal("GET", method); var routeEndpointBuilder = GetRouteEndpointBuilder(builder); - Assert.Equal("/ HTTP: GET", routeEndpointBuilder.DisplayName); + Assert.Equal("HTTP: GET /", routeEndpointBuilder.DisplayName); Assert.Equal("/", routeEndpointBuilder.RoutePattern.RawText); // Assert that we don't fallback to the route values @@ -205,7 +205,7 @@ public async Task MapVerbWithExplicitRouteParameterIsCaseInsensitive(Action "TestString"; + _ = builder.MapDelete("/", InnerGetString); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + var routeName = endpoint.Metadata.GetMetadata(); + var routeEndpointBuilder = GetRouteEndpointBuilder(builder); + Assert.Equal(name, endpointName?.EndpointName); + Assert.Equal(name, routeName?.RouteName); + Assert.Equal("HTTP: DELETE / => InnerGetString", routeEndpointBuilder.DisplayName); + } + + [Fact] + public void MapMethod_DoesNotEndpointNameForInnerMethodWithTarget() + { + var name = "InnerGetString"; + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + var testString = "TestString"; + string InnerGetString() => testString; + _ = builder.MapDelete("/", InnerGetString); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + var routeName = endpoint.Metadata.GetMetadata(); + var routeEndpointBuilder = GetRouteEndpointBuilder(builder); + Assert.Equal(name, endpointName?.EndpointName); + Assert.Equal(name, routeName?.RouteName); + Assert.Equal("HTTP: DELETE / => InnerGetString", routeEndpointBuilder.DisplayName); + } + + + [Fact] + public void MapMethod_SetsEndpointNameForMethodGroup() + { + var name = "GetString"; + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + _ = builder.MapDelete("/", GetString); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + var routeName = endpoint.Metadata.GetMetadata(); + var routeEndpointBuilder = GetRouteEndpointBuilder(builder); + Assert.Equal(name, endpointName?.EndpointName); + Assert.Equal(name, routeName?.RouteName); + Assert.Equal("HTTP: DELETE / => GetString", routeEndpointBuilder.DisplayName); + } + + [Fact] + public void WithNameOverridesDefaultEndpointName() + { + var name = "SomeCustomName"; + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + _ = builder.MapDelete("/", GetString).WithName(name); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + var routeName = endpoint.Metadata.GetMetadata(); + var routeEndpointBuilder = GetRouteEndpointBuilder(builder); + Assert.Equal(name, endpointName?.EndpointName); + Assert.Equal(name, routeName?.RouteName); + // Will still use the original method name, not the custom endpoint name + Assert.Equal("HTTP: DELETE / => GetString", routeEndpointBuilder.DisplayName); + } + + private string GetString() => "TestString"; + + [Fact] + public void MapMethod_DoesNotSetEndpointNameForLambda() + { + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(new EmptyServiceProvdier())); + _ = builder.MapDelete("/", () => { }); + + var dataSource = GetBuilderEndpointDataSource(builder); + // Trigger Endpoint build by calling getter. + var endpoint = Assert.Single(dataSource.Endpoints); + + var endpointName = endpoint.Metadata.GetMetadata(); + Assert.Null(endpointName); + } + class FromRoute : Attribute, IFromRouteMetadata { public string? Name { get; set; } diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 06084059080c..9bebda2d0709 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -78,7 +78,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string // For now, put all methods defined the same declaring type together. string controllerName; - if (methodInfo.DeclaringType is not null && !IsCompilerGenerated(methodInfo.DeclaringType)) + if (methodInfo.DeclaringType is not null && !TypeHelper.IsCompilerGeneratedType(methodInfo.DeclaringType)) { controllerName = methodInfo.DeclaringType.Name; } @@ -364,11 +364,5 @@ private static void AddActionDescriptorEndpointMetadata( actionDescriptor.EndpointMetadata = new List(endpointMetadata); } } - - // The CompilerGeneratedAttribute doesn't always get added so we also check if the type name starts with "<" - // For example, "<>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/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj index 4b86355d1087..a1170d9393e4 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj +++ b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Shared/RoslynUtils/GeneratedNameParser.cs b/src/Shared/RoslynUtils/GeneratedNameParser.cs new file mode 100644 index 000000000000..5976fa27b7b3 --- /dev/null +++ b/src/Shared/RoslynUtils/GeneratedNameParser.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +// This code is a stop-gap and exists to address the issues with extracting +// original method names from generated local functions. See https://github.com/dotnet/roslyn/issues/55651 +// for more info. +namespace Microsoft.CodeAnalysis.CSharp.Symbols +{ + internal static class GeneratedNameParser + { + /// + /// Parses generated local function name out of a generated method name. + /// + internal static bool TryParseLocalFunctionName(string generatedName, [NotNullWhen(true)] out string? originalName) + { + originalName = null; + + var startIndex = generatedName.LastIndexOf(">g__", StringComparison.Ordinal); + var endIndex = generatedName.LastIndexOf("|", StringComparison.Ordinal); + if (startIndex >= 0 && endIndex >= 0 && endIndex - startIndex > 4) + { + originalName = generatedName.Substring(startIndex + 4, endIndex - startIndex - 4); + return true; + } + + return false; + } + } +} \ No newline at end of file diff --git a/src/Shared/RoslynUtils/TypeHelper.cs b/src/Shared/RoslynUtils/TypeHelper.cs new file mode 100644 index 000000000000..b7e1f068d62e --- /dev/null +++ b/src/Shared/RoslynUtils/TypeHelper.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace System.Runtime.CompilerServices +{ + internal static class TypeHelper + { + /// + /// Checks to see if a given type is compiler generated. + /// + /// The compiler will annotate either the target type or the declaring type + /// with the CompilerGenerated attribute. We walk up the declaring types until + /// we find a CompilerGenerated attribute or declare the type as not compiler + /// generated otherwise. + /// + /// + /// The type to evaluate. + /// if is compiler generated. + internal static bool IsCompilerGeneratedType(Type? type = null) + { + if (type is not null) + { + return Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute)) || IsCompilerGeneratedType(type.DeclaringType); + } + return false; + } + + /// + /// Checks to see if a given method is compiler generated. + /// + /// The method to evaluate. + /// if is compiler generated. + internal static bool IsCompilerGeneratedMethod(MethodInfo method) + { + return Attribute.IsDefined(method, typeof(CompilerGeneratedAttribute)) || IsCompilerGeneratedType(method.DeclaringType); + } + } +} \ No newline at end of file