diff --git a/src/Http/Http.Extensions/src/EndpointMetadataContext.cs b/src/Http/Http.Extensions/src/EndpointMetadataContext.cs new file mode 100644 index 000000000000..5c6d4bb4c60a --- /dev/null +++ b/src/Http/Http.Extensions/src/EndpointMetadataContext.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.AspNetCore.Http.Metadata; + +/// +/// Represents the information accessible during endpoint creation by types that implement . +/// +public sealed class EndpointMetadataContext +{ + /// + /// Gets the associated with the current route handler. + /// + public MethodInfo Method { get; init; } = null!; // Is initialized when created by RequestDelegateFactory + + /// + /// Gets the instance used to access application services. + /// + public IServiceProvider? Services { get; init; } + + /// + /// Gets the list of objects that will be added to the metadata of the endpoint. + /// + public IList EndpointMetadata { get; init; } = null!; // Is initialized when created by RequestDelegateFactory +} diff --git a/src/Http/Http.Extensions/src/EndpointParameterMetadataContext.cs b/src/Http/Http.Extensions/src/EndpointParameterMetadataContext.cs new file mode 100644 index 000000000000..1f7d5445ed1a --- /dev/null +++ b/src/Http/Http.Extensions/src/EndpointParameterMetadataContext.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.AspNetCore.Http.Metadata; + +/// +/// Represents the information accessible during endpoint creation by types that implement . +/// +public sealed class EndpointParameterMetadataContext +{ + /// + /// Gets the parameter of the route handler delegate of the endpoint being created. + /// + public ParameterInfo Parameter { get; init; } = null!; // Is initialized when created by RequestDelegateFactory + + /// + /// Gets the associated with the current route handler. + /// + public IServiceProvider? Services { get; init; } + + /// + /// Gets the list of objects that will be added to the metadata of the endpoint. + /// + public IList EndpointMetadata { get; init; } = null!; // Is initialized when created by RequestDelegateFactory +} diff --git a/src/Http/Http.Extensions/src/IEndpointMetadataProvider.cs b/src/Http/Http.Extensions/src/IEndpointMetadataProvider.cs new file mode 100644 index 000000000000..b7bda01e4716 --- /dev/null +++ b/src/Http/Http.Extensions/src/IEndpointMetadataProvider.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Metadata; + +/// +/// Indicates that a type provides a static method that provides metadata when declared as a parameter type or the +/// returned type of an route handler delegate. +/// +public interface IEndpointMetadataProvider +{ + /// + /// Populates metadata for the related . + /// + /// + /// This method is called by when creating a . + /// The property of will contain + /// the initial metadata for the endpoint.
+ /// Add or remove objects on to affect the metadata of the endpoint. + ///
+ /// The . + static abstract void PopulateMetadata(EndpointMetadataContext context); +} diff --git a/src/Http/Http.Extensions/src/IEndpointParameterMetadataProvider.cs b/src/Http/Http.Extensions/src/IEndpointParameterMetadataProvider.cs new file mode 100644 index 000000000000..45c29dbbf347 --- /dev/null +++ b/src/Http/Http.Extensions/src/IEndpointParameterMetadataProvider.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Metadata; + +/// +/// Indicates that a type provides a static method that provides metadata when declared as the +/// parameter type of an route handler delegate. +/// +public interface IEndpointParameterMetadataProvider +{ + /// + /// Populates metadata for the related . + /// + /// + /// This method is called by when creating a . + /// The property of will contain + /// the initial metadata for the endpoint.
+ /// Add or remove objects on to affect the metadata of the endpoint. + ///
+ /// The . + static abstract void PopulateMetadata(EndpointParameterMetadataContext parameterContext); +} diff --git a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt index f5825c4e8476..b6e83b95e2cb 100644 --- a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt @@ -1,4 +1,26 @@ #nullable enable +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.EndpointMetadata.get -> System.Collections.Generic.IList! +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.EndpointMetadata.init -> void +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.EndpointMetadataContext() -> void +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.Method.get -> System.Reflection.MethodInfo! +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.Method.init -> void +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.Services.get -> System.IServiceProvider? +Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext.Services.init -> void +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext.EndpointMetadata.get -> System.Collections.Generic.IList! +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext.EndpointMetadata.init -> void +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext.EndpointParameterMetadataContext() -> void +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext.Parameter.get -> System.Reflection.ParameterInfo! +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext.Parameter.init -> void +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext.Services.get -> System.IServiceProvider? +Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext.Services.init -> void +Microsoft.AspNetCore.Http.Metadata.IEndpointMetadataProvider +Microsoft.AspNetCore.Http.Metadata.IEndpointMetadataProvider.PopulateMetadata(Microsoft.AspNetCore.Http.Metadata.EndpointMetadataContext! context) -> void +Microsoft.AspNetCore.Http.Metadata.IEndpointParameterMetadataProvider +Microsoft.AspNetCore.Http.Metadata.IEndpointParameterMetadataProvider.PopulateMetadata(Microsoft.AspNetCore.Http.Metadata.EndpointParameterMetadataContext! parameterContext) -> void +Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.InitialEndpointMetadata.get -> System.Collections.Generic.IEnumerable? +Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.InitialEndpointMetadata.init -> void Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilterFactories.get -> System.Collections.Generic.IReadOnlyList!>? Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilterFactories.init -> void Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index c32a6a790147..b7a9b7fd111b 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -40,6 +40,8 @@ public static partial class RequestDelegateFactory private static readonly MethodInfo StringResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteWriteStringResponseAsync), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo StringIsNullOrEmptyMethod = typeof(string).GetMethod(nameof(string.IsNullOrEmpty), BindingFlags.Static | BindingFlags.Public)!; private static readonly MethodInfo WrapObjectAsValueTaskMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WrapObjectAsValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo PopulateMetadataForParameterMethod = typeof(RequestDelegateFactory).GetMethod(nameof(PopulateMetadataForParameter), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo PopulateMetadataForEndpointMethod = typeof(RequestDelegateFactory).GetMethod(nameof(PopulateMetadataForEndpoint), BindingFlags.NonPublic | BindingFlags.Static)!; // Call WriteAsJsonAsync() to serialize the runtime return type rather than the declared return type. // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-polymorphism @@ -159,9 +161,11 @@ public static RequestDelegateResult Create(MethodInfo methodInfo, Func targetableRequestDelegate(targetFactory(httpContext), httpContext), factoryContext.Metadata); } - private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions? options) => - new() + private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions? options) + { + var context = new FactoryContext { + ServiceProvider = options?.ServiceProvider, ServiceProviderIsService = options?.ServiceProvider?.GetService(), RouteParameters = options?.RouteParameterNames?.ToList(), ThrowOnBadRequest = options?.ThrowOnBadRequest ?? false, @@ -169,6 +173,14 @@ private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions Filters = options?.RouteHandlerFilterFactories?.ToList() }; + if (options?.InitialEndpointMetadata is not null) + { + context.Metadata.AddRange(options.InitialEndpointMetadata); + } + + return context; + } + private static Func CreateTargetableRequestDelegate(MethodInfo methodInfo, Expression? targetExpression, FactoryContext factoryContext) { // Non void return type @@ -187,10 +199,20 @@ private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions // return default; // } + // Add MethodInfo as first metadata item + factoryContext.Metadata.Insert(0, methodInfo); + + // CreateArguments will add metadata inferred from parameter details var arguments = CreateArguments(methodInfo.GetParameters(), factoryContext); var returnType = methodInfo.ReturnType; factoryContext.MethodCall = CreateMethodCall(methodInfo, targetExpression, arguments); + // Add metadata provided by the delegate return type and parameter types next, this will be more specific than inferred metadata from above + AddTypeProvidedMetadata(methodInfo, factoryContext.Metadata, factoryContext.ServiceProvider); + + // Add method attributes as metadata *after* any inferred metadata so that the attributes hava a higher specificity + AddMethodAttributesAsMetadata(methodInfo, factoryContext.Metadata); + // If there are filters registered on the route handler, then we update the method call and // return type associated with the request to allow for the filter invocation pipeline. if (factoryContext.Filters is { Count: > 0 }) @@ -255,6 +277,82 @@ target is null return filteredInvocation; } + private static void AddTypeProvidedMetadata(MethodInfo methodInfo, List metadata, IServiceProvider? services) + { + object?[]? invokeArgs = null; + + // Get metadata from parameter types + var parameters = methodInfo.GetParameters(); + foreach (var parameter in parameters) + { + if (typeof(IEndpointParameterMetadataProvider).IsAssignableFrom(parameter.ParameterType)) + { + // Parameter type implements IEndpointParameterMetadataProvider + var parameterContext = new EndpointParameterMetadataContext + { + Parameter = parameter, + EndpointMetadata = metadata, + Services = services + }; + invokeArgs ??= new object[1]; + invokeArgs[0] = parameterContext; + PopulateMetadataForParameterMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs); + } + + if (typeof(IEndpointMetadataProvider).IsAssignableFrom(parameter.ParameterType)) + { + // Parameter type implements IEndpointMetadataProvider + var context = new EndpointMetadataContext + { + Method = methodInfo, + EndpointMetadata = metadata, + Services = services + }; + invokeArgs ??= new object[1]; + invokeArgs[0] = context; + PopulateMetadataForEndpointMethod.MakeGenericMethod(parameter.ParameterType).Invoke(null, invokeArgs); + } + } + + // Get metadata from return type + if (methodInfo.ReturnType is not null && typeof(IEndpointMetadataProvider).IsAssignableFrom(methodInfo.ReturnType)) + { + // Return type implements IEndpointMetadataProvider + var context = new EndpointMetadataContext + { + Method = methodInfo, + EndpointMetadata = metadata, + Services = services + }; + invokeArgs ??= new object[1]; + invokeArgs[0] = context; + PopulateMetadataForEndpointMethod.MakeGenericMethod(methodInfo.ReturnType).Invoke(null, invokeArgs); + } + } + + private static void PopulateMetadataForParameter(EndpointParameterMetadataContext parameterContext) + where T : IEndpointParameterMetadataProvider + { + T.PopulateMetadata(parameterContext); + } + + private static void PopulateMetadataForEndpoint(EndpointMetadataContext context) + where T : IEndpointMetadataProvider + { + T.PopulateMetadata(context); + } + + private static void AddMethodAttributesAsMetadata(MethodInfo methodInfo, List metadata) + { + var attributes = methodInfo.GetCustomAttributes(); + + // This can be null if the delegate is a dynamic method or compiled from an expression tree + if (attributes is not null) + { + metadata.AddRange(attributes); + } + } + private static Expression[] CreateArguments(ParameterInfo[]? parameters, FactoryContext factoryContext) { if (parameters is null || parameters.Length == 0) @@ -1669,6 +1767,7 @@ private static async Task ExecuteResultWriteResponse(IResult? result, HttpContex private class FactoryContext { // Options + public IServiceProvider? ServiceProvider { get; init; } public IServiceProviderIsService? ServiceProviderIsService { get; init; } public List? RouteParameters { get; init; } public bool ThrowOnBadRequest { get; init; } @@ -1687,7 +1786,7 @@ private class FactoryContext public bool HasMultipleBodyParameters { get; set; } public bool HasInferredBody { get; set; } - public List Metadata { get; } = new(); + public List Metadata { get; internal set; } = new(); public NullabilityInfoContext NullabilityContext { get; } = new(); diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactoryOptions.cs b/src/Http/Http.Extensions/src/RequestDelegateFactoryOptions.cs index 70207f9c63d8..9b367dfcfcf0 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactoryOptions.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactoryOptions.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Http; public sealed class RequestDelegateFactoryOptions { /// - /// The instance used to detect if handler parameters are services. + /// The instance used to access application services. /// public IServiceProvider? ServiceProvider { get; init; } @@ -36,4 +36,15 @@ public sealed class RequestDelegateFactoryOptions /// The list of filters that must run in the pipeline for a given route handler. /// public IReadOnlyList>? RouteHandlerFilterFactories { get; init; } + + /// + /// The initial endpoint metadata to add as part of the creation of the . + /// + /// + /// This metadata will be included in before any metadata inferred during creation of the + /// and before any metadata provided by types in the delegate signature that implement + /// or , i.e. this metadata will be less specific than any + /// inferred by the call to . + /// + public IEnumerable? InitialEndpointMetadata { get; init; } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index f597d15edc15..5eea1bcf753c 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -4560,6 +4560,244 @@ string HelloName(string name) Assert.Equal("HELLO, TESTNAMEPREFIX!", responseBody); } + [Fact] + public void Create_AddsDelegateMethodInfo_AsMetadata() + { + // Arrange + var @delegate = () => "Hello"; + + // Act + var result = RequestDelegateFactory.Create(@delegate); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is MethodInfo); + } + + [Fact] + public void Create_AddsDelegateMethodInfo_AsFirstMetadata() + { + // Arrange + var @delegate = (AddsCustomParameterMetadata param1) => "Hello"; + var customMetadata = new CustomEndpointMetadata(); + var options = new RequestDelegateFactoryOptions { InitialEndpointMetadata = new[] { customMetadata } }; + + // Act + var result = RequestDelegateFactory.Create(@delegate, options); + + // Assert + var firstMetadata = result.EndpointMetadata[0]; + Assert.IsAssignableFrom(firstMetadata); + } + + [Fact] + public void Create_AddsDelegateAttributes_AsMetadata() + { + // Arrange + var @delegate = [Attribute1, Attribute2] () => { }; + + // Act + var result = RequestDelegateFactory.Create(@delegate); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is Attribute1); + Assert.Contains(result.EndpointMetadata, m => m is Attribute2); + } + + [Fact] + public void Create_AddsDelegateAttributes_AsLastMetadata() + { + // Arrange + var @delegate = [Attribute1] (AddsCustomParameterMetadata param1) => { }; + var options = new RequestDelegateFactoryOptions { InitialEndpointMetadata = new[] { new CustomEndpointMetadata() } }; + + // Act + var result = RequestDelegateFactory.Create(@delegate, options); + + // Assert + var lastMetadata = result.EndpointMetadata.Last(); + Assert.IsAssignableFrom(lastMetadata); + } + + [Fact] + public void Create_DiscoversMetadata_FromParametersImplementingIEndpointParameterMetadataProvider() + { + // Arrange + var @delegate = (AddsCustomParameterMetadataBindable param1, AddsCustomParameterMetadata param2) => { }; + + // Act + var result = RequestDelegateFactory.Create(@delegate); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is ParameterNameMetadata { Name: "param1" }); + Assert.Contains(result.EndpointMetadata, m => m is ParameterNameMetadata { Name: "param2" }); + } + + [Fact] + public void Create_DiscoversMetadata_FromParametersImplementingIEndpointMetadataProvider() + { + // Arrange + var @delegate = (AddsCustomParameterMetadata param1) => { }; + + // Act + var result = RequestDelegateFactory.Create(@delegate); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Parameter }); + } + + [Fact] + public void Create_DiscoversEndpointMetadata_FromReturnTypeImplementingIEndpointMetadataProvider() + { + // Arrange + var @delegate = () => new AddsCustomEndpointMetadataResult(); + + // Act + var result = RequestDelegateFactory.Create(@delegate); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.ReturnType }); + } + + [Fact] + public void Create_CombinesDefaultMetadata_AndMetadataFromReturnTypesImplementingIEndpointMetadataProvider() + { + // Arrange + var @delegate = () => new CountsDefaultEndpointMetadataResult(); + var options = new RequestDelegateFactoryOptions + { + InitialEndpointMetadata = new List + { + new CustomEndpointMetadata { Source = MetadataSource.Caller } + } + }; + + // Act + var result = RequestDelegateFactory.Create(@delegate, options); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller }); + // Expecting '2' as only MethodInfo and initial metadata will be in the metadata list when this metadata item is added + Assert.Contains(result.EndpointMetadata, m => m is DefaultMetadataCountMetadata { Count: 2 }); + } + + [Fact] + public void Create_CombinesDefaultMetadata_AndMetadataFromParameterTypesImplementingIEndpointParameterMetadataProvider() + { + // Arrange + var @delegate = (AddsCustomParameterMetadata param1) => "Hello"; + var options = new RequestDelegateFactoryOptions + { + InitialEndpointMetadata = new List + { + new CustomEndpointMetadata { Source = MetadataSource.Caller } + } + }; + + // Act + var result = RequestDelegateFactory.Create(@delegate, options); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller }); + Assert.Contains(result.EndpointMetadata, m => m is ParameterNameMetadata { Name: "param1" }); + } + + [Fact] + public void Create_CombinesDefaultMetadata_AndMetadataFromParameterTypesImplementingIEndpointMetadataProvider() + { + // Arrange + var @delegate = (AddsCustomParameterMetadata param1) => "Hello"; + var options = new RequestDelegateFactoryOptions + { + InitialEndpointMetadata = new List + { + new CustomEndpointMetadata { Source = MetadataSource.Caller } + } + }; + + // Act + var result = RequestDelegateFactory.Create(@delegate, options); + + // Assert + Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Caller }); + Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Parameter }); + } + + [Fact] + public void Create_CombinesAllMetadata_InCorrectOrder() + { + // Arrange + var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new CountsDefaultEndpointMetadataResult(); + var options = new RequestDelegateFactoryOptions + { + InitialEndpointMetadata = new List + { + new CustomEndpointMetadata { Source = MetadataSource.Caller } + } + }; + + // Act + var result = RequestDelegateFactory.Create(@delegate, options); + + // Assert + Assert.Collection(result.EndpointMetadata, + // MethodInfo + m => Assert.IsAssignableFrom(m), + // Initial metadata from RequestDelegateFactoryOptions.InitialEndpointMetadata + m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Caller }), + // Inferred AcceptsMetadata from RDF for complex type + m => Assert.True(m is AcceptsMetadata am && am.RequestType == typeof(AddsCustomParameterMetadata)), + // Metadata provided by parameters implementing IEndpointParameterMetadataProvider + m => Assert.True(m is ParameterNameMetadata { Name: "param1" }), + // Metadata provided by parameters implementing IEndpointMetadataProvider + m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Parameter }), + // Metadata provided by return type implementing IEndpointMetadataProvider + m => Assert.True(m is DefaultMetadataCountMetadata { Count: 5 }), + // Handler delegate attributes + m => Assert.IsAssignableFrom(m), // NullableContextAttribute + m => Assert.IsType(m), + m => Assert.IsType(m)); + } + + [Fact] + public void Create_AllowsRemovalOfDefaultMetadata_ByReturnTypesImplementingIEndpointMetadataProvider() + { + // Arrange + var @delegate = (Todo todo) => new RemovesAcceptsMetadataResult(); + + // Act + var result = RequestDelegateFactory.Create(@delegate); + + // Assert + Assert.DoesNotContain(result.EndpointMetadata, m => m is IAcceptsMetadata); + } + + [Fact] + public void Create_AllowsRemovalOfDefaultMetadata_ByParameterTypesImplementingIEndpointParameterMetadataProvider() + { + // Arrange + var @delegate = (RemovesAcceptsParameterMetadata param1) => "Hello"; + + // Act + var result = RequestDelegateFactory.Create(@delegate); + + // Assert + Assert.DoesNotContain(result.EndpointMetadata, m => m is IAcceptsMetadata); + } + + [Fact] + public void Create_AllowsRemovalOfDefaultMetadata_ByParameterTypesImplementingIEndpointMetadataProvider() + { + // Arrange + var @delegate = (RemovesAcceptsParameterMetadata param1) => "Hello"; + var options = new RequestDelegateFactoryOptions(); + + // Act + var result = RequestDelegateFactory.Create(@delegate, options); + + // Assert + Assert.DoesNotContain(result.EndpointMetadata, m => m is IAcceptsMetadata); + } + private DefaultHttpContext CreateHttpContext() { var responseFeature = new TestHttpResponseFeature(); @@ -4576,6 +4814,153 @@ private DefaultHttpContext CreateHttpContext() }; } + private class Attribute1 : Attribute + { + } + + private class Attribute2 : Attribute + { + } + + private class AddsCustomEndpointMetadataResult : IEndpointMetadataProvider, IResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata?.Add(new CustomEndpointMetadata { Source = MetadataSource.ReturnType }); + } + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } + + private class AddsNoEndpointMetadataResult : IEndpointMetadataProvider, IResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + + } + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } + + private class CountsDefaultEndpointMetadataResult : IEndpointMetadataProvider, IResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + var defaultMetadataCount = context.EndpointMetadata?.Count; + context.EndpointMetadata?.Add(new DefaultMetadataCountMetadata { Count = defaultMetadataCount ?? 0 }); + } + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } + + private class RemovesAcceptsParameterMetadata : IEndpointParameterMetadataProvider + { + public static void PopulateMetadata(EndpointParameterMetadataContext parameterContext) + { + if (parameterContext.EndpointMetadata is not null) + { + for (int i = parameterContext.EndpointMetadata.Count - 1; i >= 0; i--) + { + var metadata = parameterContext.EndpointMetadata[i]; + if (metadata is IAcceptsMetadata) + { + parameterContext.EndpointMetadata.RemoveAt(i); + } + } + } + } + } + + private class RemovesAcceptsMetadata : IEndpointMetadataProvider + { + public static void PopulateMetadata(EndpointMetadataContext parameterContext) + { + if (parameterContext.EndpointMetadata is not null) + { + for (int i = parameterContext.EndpointMetadata.Count - 1; i >= 0; i--) + { + var metadata = parameterContext.EndpointMetadata[i]; + if (metadata is IAcceptsMetadata) + { + parameterContext.EndpointMetadata.RemoveAt(i); + } + } + } + } + } + + private class RemovesAcceptsMetadataResult : IEndpointMetadataProvider, IResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + if (context.EndpointMetadata is not null) + { + for (int i = context.EndpointMetadata.Count - 1; i >= 0; i--) + { + var metadata = context.EndpointMetadata[i]; + if (metadata is IAcceptsMetadata) + { + context.EndpointMetadata.RemoveAt(i); + } + } + } + } + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } + + private class AddsCustomParameterMetadata : IEndpointParameterMetadataProvider, IEndpointMetadataProvider + { + public static void PopulateMetadata(EndpointParameterMetadataContext parameterContext) + { + parameterContext.EndpointMetadata?.Add(new ParameterNameMetadata { Name = parameterContext.Parameter?.Name }); + } + + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata?.Add(new CustomEndpointMetadata { Source = MetadataSource.Parameter }); + } + } + + private class AddsCustomParameterMetadataBindable : IEndpointParameterMetadataProvider, IEndpointMetadataProvider + { + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => default; + + public static void PopulateMetadata(EndpointParameterMetadataContext parameterContext) + { + parameterContext.EndpointMetadata?.Add(new ParameterNameMetadata { Name = parameterContext.Parameter?.Name }); + } + + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata?.Add(new CustomEndpointMetadata { Source = MetadataSource.Parameter }); + } + } + + private class DefaultMetadataCountMetadata + { + public int Count { get; init; } + } + + private class ParameterNameMetadata + { + public string? Name { get; init; } + } + + private class CustomEndpointMetadata + { + public string? Data { get; init; } + + public MetadataSource Source { get; init; } + } + + private enum MetadataSource + { + Caller, + Parameter, + ReturnType + } + private class Todo : ITodo { public int Id { get; set; } diff --git a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs index d8990104cb12..ed1a94a4ac12 100644 --- a/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/EndpointRouteBuilderExtensions.cs @@ -322,10 +322,12 @@ public static RouteHandlerBuilder MapMethods( } } - var builder = endpoints.Map(RoutePatternFactory.Parse(pattern), handler, disableInferredBody); + var initialMetadata = new object[] { new HttpMethodMetadata(httpMethods) }; + var builder = endpoints.Map(RoutePatternFactory.Parse(pattern), handler, disableInferredBody, initialMetadata); + // 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; static bool ShouldDisableInferredBody(string method) @@ -457,7 +459,8 @@ private static RouteHandlerBuilder Map( this IEndpointRouteBuilder endpoints, RoutePattern pattern, Delegate handler, - bool disableInferBodyFromParameters) + bool disableInferBodyFromParameters, + IEnumerable? initialEndpointMetadata = null) { if (endpoints is null) { @@ -491,12 +494,6 @@ private static RouteHandlerBuilder 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 "handler" and not the RequestDelegate? - - // Add MethodInfo as metadata to assist with OpenAPI generation for the endpoint. - builder.Metadata.Add(handler.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. @@ -523,27 +520,17 @@ private static RouteHandlerBuilder Map( RouteParameterNames = routeParams, ThrowOnBadRequest = routeHandlerOptions?.Value.ThrowOnBadRequest ?? false, DisableInferBodyFromParameters = disableInferBodyFromParameters, - RouteHandlerFilterFactories = routeHandlerBuilder.RouteHandlerFilterFactories + RouteHandlerFilterFactories = routeHandlerBuilder.RouteHandlerFilterFactories, + InitialEndpointMetadata = initialEndpointMetadata }; var filteredRequestDelegateResult = RequestDelegateFactory.Create(handler, options); + // Add request delegate metadata foreach (var metadata in filteredRequestDelegateResult.EndpointMetadata) { endpointBuilder.Metadata.Add(metadata); } - // We add attributes on the handler after those automatically generated by the - // RDF since they have a higher specificity. - var attributes = handler.Method.GetCustomAttributes(); - - // This can be null if the delegate is a dynamic method or compiled from an expression tree - if (attributes is not null) - { - foreach (var attribute in attributes) - { - endpointBuilder.Metadata.Add(attribute); - } - } endpointBuilder.RequestDelegate = filteredRequestDelegateResult.RequestDelegate; }); diff --git a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs index df21fbb1d7f1..54f4b313ecc1 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RequestDelegateEndpointRouteBuilderExtensionsTest.cs @@ -3,9 +3,11 @@ using System.IO.Pipelines; using System.Linq.Expressions; +using System.Reflection; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; @@ -221,6 +223,38 @@ public void AddingMetadataAfterBuildingEndpointThrows(Func(() => endpointBuilder.WithMetadata(new RouteNameMetadata("Foo"))); } + [Fact] + public void Map_AddsMetadata_InCorrectOrder() + { + // Arrange + var builder = new DefaultEndpointRouteBuilder(Mock.Of()); + var @delegate = [Attribute1, Attribute2] (AddsCustomParameterMetadata param1) => new AddsCustomEndpointMetadataResult(); + + // Act + builder.Map("/test", @delegate); + + // Assert + var ds = GetBuilderEndpointDataSource(builder); + var endpoint = Assert.Single(ds.Endpoints); + var metadata = endpoint.Metadata; + + Assert.Collection(metadata, + m => Assert.IsAssignableFrom(m), + m => Assert.IsAssignableFrom(m), + m => + { + Assert.IsAssignableFrom(m); + Assert.Equal(MetadataSource.Parameter, ((CustomEndpointMetadata)m).Source); + }, + m => + { + Assert.IsAssignableFrom(m); + Assert.Equal(MetadataSource.ReturnType, ((CustomEndpointMetadata)m).Source); + }, + m => Assert.IsAssignableFrom(m), + m => Assert.IsAssignableFrom(m)); + } + [Attribute1] [Attribute2] private static Task Handle(HttpContext context) => Task.CompletedTask; @@ -247,4 +281,47 @@ private class Attribute1 : Attribute private class Attribute2 : Attribute { } + + private class AddsCustomEndpointMetadataResult : IEndpointMetadataProvider, IResult + { + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata.Add(new CustomEndpointMetadata { Source = MetadataSource.ReturnType }); + } + + public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); + } + + private class AddsCustomParameterMetadata : IEndpointParameterMetadataProvider, IEndpointMetadataProvider + { + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => default; + + public static void PopulateMetadata(EndpointParameterMetadataContext parameterContext) + { + parameterContext.EndpointMetadata.Add(new ParameterNameMetadata { Name = parameterContext.Parameter.Name }); + } + + public static void PopulateMetadata(EndpointMetadataContext context) + { + context.EndpointMetadata.Add(new CustomEndpointMetadata { Source = MetadataSource.Parameter }); + } + } + + private class ParameterNameMetadata + { + public string Name { get; init; } + } + + private class CustomEndpointMetadata + { + public string Data { get; init; } + + public MetadataSource Source { get; init; } + } + + private enum MetadataSource + { + Parameter, + ReturnType + } } diff --git a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs index b9d4e586076f..90aaf401d54d 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RouteHandlerEndpointRouteBuilderExtensionsTest.cs @@ -81,8 +81,8 @@ void TestAction() static string GetMethod(IHttpMethodMetadata metadata) => Assert.Single(metadata.HttpMethods); Assert.Equal(3, metadataArray.Length); - Assert.Equal("ATTRIBUTE", GetMethod(metadataArray[0])); - Assert.Equal("METHOD", GetMethod(metadataArray[1])); + Assert.Equal("METHOD", GetMethod(metadataArray[0])); + Assert.Equal("ATTRIBUTE", GetMethod(metadataArray[1])); Assert.Equal("BUILDER", GetMethod(metadataArray[2])); Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata()!.HttpMethods.Single());