diff --git a/src/Http/Http.Abstractions/src/IBindableFromHttpContextOfT.cs b/src/Http/Http.Abstractions/src/IBindableFromHttpContextOfT.cs new file mode 100644 index 000000000000..0f414b849e5f --- /dev/null +++ b/src/Http/Http.Abstractions/src/IBindableFromHttpContextOfT.cs @@ -0,0 +1,22 @@ +// 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; + +/// +/// Defines a mechanism for creating an instance of a type from an when binding parameters for an endpoint +/// route handler delegate. +/// +/// The type that implements this interface. +public interface IBindableFromHttpContext where TSelf : class, IBindableFromHttpContext +{ + /// + /// Creates an instance of from the . + /// + /// The for the current request. + /// The for the parameter of the route handler delegate the returned instance will populate. + /// The instance of . + static abstract ValueTask BindAsync(HttpContext context, ParameterInfo parameter); +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index b8d0ab94d187..a307310dc1af 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -9,6 +9,8 @@ Microsoft.AspNetCore.Http.DefaultRouteHandlerInvocationContext Microsoft.AspNetCore.Http.DefaultRouteHandlerInvocationContext.DefaultRouteHandlerInvocationContext(Microsoft.AspNetCore.Http.HttpContext! httpContext, params object![]! arguments) -> void Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object! Microsoft.AspNetCore.Http.EndpointMetadataCollection.GetRequiredMetadata() -> T! +Microsoft.AspNetCore.Http.IBindableFromHttpContext +Microsoft.AspNetCore.Http.IBindableFromHttpContext.BindAsync(Microsoft.AspNetCore.Http.HttpContext! context, System.Reflection.ParameterInfo! parameter) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.IRouteHandlerFilter.InvokeAsync(Microsoft.AspNetCore.Http.RouteHandlerInvocationContext! context, Microsoft.AspNetCore.Http.RouteHandlerFilterDelegate! next) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string? diff --git a/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs b/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs index 6a1085ca2c4f..725fb9587855 100644 --- a/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs +++ b/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs @@ -3,6 +3,7 @@ #nullable enable +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq.Expressions; using System.Reflection; @@ -302,6 +303,10 @@ public static IEnumerable BindAsyncParameterInfoData { GetFirstParameter((BindAsyncFromInterfaceWithParameterInfo arg) => BindAsyncFromInterfaceWithParameterInfoMethod(arg)) }, + new[] + { + GetFirstParameter((BindAsyncFromStaticAbstractInterfaceAndBindAsync arg) => BindAsyncFromImplicitStaticAbstractInterfaceMethodInsteadOfReflectionMatchedMethod(arg)) + }, }; } } @@ -320,6 +325,27 @@ public void HasBindAsyncMethod_ReturnsTrueForNullableReturningBindAsyncStructMet Assert.True(new ParameterBindingMethodCache().HasBindAsyncMethod(parameterInfo)); } + [Fact] + public void HasBindAsyncMethod_ReturnsTrueForClassImplicitlyImplementingIBindableFromHttpContext() + { + var parameterInfo = GetFirstParameter((BindAsyncFromImplicitStaticAbstractInterface arg) => BindAsyncFromImplicitStaticAbstractInterfaceMethod(arg)); + Assert.True(new ParameterBindingMethodCache().HasBindAsyncMethod(parameterInfo)); + } + + [Fact] + public void HasBindAsyncMethod_ReturnsTrueForClassExplicitlyImplementingIBindableFromHttpContext() + { + var parameterInfo = GetFirstParameter((BindAsyncFromExplicitStaticAbstractInterface arg) => BindAsyncFromExplicitStaticAbstractInterfaceMethod(arg)); + Assert.True(new ParameterBindingMethodCache().HasBindAsyncMethod(parameterInfo)); + } + + [Fact] + public void HasBindAsyncMethod_ReturnsTrueForClassImplementingIBindableFromHttpContextAndNonInterfaceBindAsyncMethod() + { + var parameterInfo = GetFirstParameter((BindAsyncFromStaticAbstractInterfaceAndBindAsync arg) => BindAsyncFromImplicitStaticAbstractInterfaceMethodInsteadOfReflectionMatchedMethod(arg)); + Assert.True(new ParameterBindingMethodCache().HasBindAsyncMethod(parameterInfo)); + } + [Fact] public void FindBindAsyncMethod_FindsNonNullableReturningBindAsyncMethodGivenNullableType() { @@ -327,6 +353,42 @@ public void FindBindAsyncMethod_FindsNonNullableReturningBindAsyncMethodGivenNul Assert.True(new ParameterBindingMethodCache().HasBindAsyncMethod(parameterInfo)); } + [Fact] + public async Task FindBindAsyncMethod_FindsForClassImplicitlyImplementingIBindableFromHttpContext() + { + var parameterInfo = GetFirstParameter((BindAsyncFromImplicitStaticAbstractInterface arg) => BindAsyncFromImplicitStaticAbstractInterfaceMethod(arg)); + var cache = new ParameterBindingMethodCache(); + Assert.True(cache.HasBindAsyncMethod(parameterInfo)); + var methodFound = cache.FindBindAsyncMethod(parameterInfo); + + var parseHttpContext = Expression.Lambda>>(methodFound.Expression!, + ParameterBindingMethodCache.HttpContextExpr).Compile(); + + var httpContext = new DefaultHttpContext(); + + var result = await parseHttpContext(httpContext); + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public async Task FindBindAsyncMethod_FindsForClassExplicitlyImplementingIBindableFromHttpContext() + { + var parameterInfo = GetFirstParameter((BindAsyncFromExplicitStaticAbstractInterface arg) => BindAsyncFromExplicitStaticAbstractInterfaceMethod(arg)); + var cache = new ParameterBindingMethodCache(); + Assert.True(cache.HasBindAsyncMethod(parameterInfo)); + var methodFound = cache.FindBindAsyncMethod(parameterInfo); + + var parseHttpContext = Expression.Lambda>>(methodFound.Expression!, + ParameterBindingMethodCache.HttpContextExpr).Compile(); + + var httpContext = new DefaultHttpContext(); + + var result = await parseHttpContext(httpContext); + Assert.NotNull(result); + Assert.IsType(result); + } + [Fact] public async Task FindBindAsyncMethod_FindsFallbackMethodWhenPreferredMethodsReturnTypeIsWrong() { @@ -359,6 +421,25 @@ public async Task FindBindAsyncMethod_FindsFallbackMethodFromInheritedWhenPrefer Assert.Null(await parseHttpContext(httpContext)); } + [Fact] + public async Task FindBindAsyncMethod_FindsMethodFromStaticAbstractInterfaceWhenValidNonInterfaceMethodAlsoExists() + { + var parameterInfo = GetFirstParameter((BindAsyncFromStaticAbstractInterfaceAndBindAsync arg) => BindAsyncFromImplicitStaticAbstractInterfaceMethodInsteadOfReflectionMatchedMethod(arg)); + var cache = new ParameterBindingMethodCache(); + Assert.True(cache.HasBindAsyncMethod(parameterInfo)); + var methodFound = cache.FindBindAsyncMethod(parameterInfo); + + var parseHttpContext = Expression.Lambda>>(methodFound.Expression!, + ParameterBindingMethodCache.HttpContextExpr).Compile(); + + var httpContext = new DefaultHttpContext(); + var result = await parseHttpContext(httpContext); + + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(BindAsyncSource.InterfaceStaticAbstractImplicit, ((BindAsyncFromStaticAbstractInterfaceAndBindAsync)result).BoundFrom); + } + [Theory] [InlineData(typeof(ClassWithParameterlessConstructor))] [InlineData(typeof(RecordClassParameterlessConstructor))] @@ -499,6 +580,7 @@ public static TheoryData InvalidBindAsyncTypesData typeof(BindAsyncWithParameterInfoWrongTypeInherit), typeof(BindAsyncWrongTypeFromInterface), typeof(BindAsyncBothBadMethods), + typeof(BindAsyncFromStaticAbstractInterfaceWrongType) }; } } @@ -627,7 +709,6 @@ private static void BindAsyncRecordMethod(BindAsyncRecord arg) { } private static void BindAsyncStructMethod(BindAsyncStruct arg) { } private static void BindAsyncNullableStructMethod(BindAsyncStruct? arg) { } private static void NullableReturningBindAsyncStructMethod(NullableReturningBindAsyncStruct arg) { } - private static void BindAsyncSingleArgRecordMethod(BindAsyncSingleArgRecord arg) { } private static void BindAsyncSingleArgStructMethod(BindAsyncSingleArgStruct arg) { } private static void InheritBindAsyncMethod(InheritBindAsync arg) { } @@ -639,6 +720,10 @@ private static void BindAsyncFromClassAndInterfaceMethod(BindAsyncFromClassAndIn private static void BindAsyncFromInterfaceWithParameterInfoMethod(BindAsyncFromInterfaceWithParameterInfo args) { } private static void BindAsyncFallbackMethod(BindAsyncFallsBack? arg) { } private static void BindAsyncBadMethodMethod(BindAsyncBadMethod? arg) { } + private static void BindAsyncFromImplicitStaticAbstractInterfaceMethod(BindAsyncFromImplicitStaticAbstractInterface arg) { } + private static void BindAsyncFromExplicitStaticAbstractInterfaceMethod(BindAsyncFromExplicitStaticAbstractInterface arg) { } + private static void BindAsyncFromImplicitStaticAbstractInterfaceMethodInsteadOfReflectionMatchedMethod(BindAsyncFromStaticAbstractInterfaceAndBindAsync arg) { } + private static void BindAsyncFromStaticAbstractInterfaceWrongTypeMethod(BindAsyncFromStaticAbstractInterfaceWrongType arg) { } private static ParameterInfo GetFirstParameter(Expression> expr) { @@ -646,6 +731,12 @@ private static ParameterInfo GetFirstParameter(Expression> expr) return mc.Method.GetParameters()[0]; } + private static ParameterInfo GetParameterAtIndex(Expression> expr, int paramIndex) + { + var mc = (MethodCallExpression)expr.Body; + return mc.Method.GetParameters()[paramIndex]; + } + private record TryParseStringRecord(int Value) { public static bool TryParse(string? value, IFormatProvider formatProvider, out TryParseStringRecord? result) @@ -1347,6 +1438,59 @@ public RecordStructWithInvalidConstructors(int foo, int bar) } } + private class BindAsyncFromImplicitStaticAbstractInterface : IBindableFromHttpContext + { + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + return ValueTask.FromResult(new()); + } + } + + private class BindAsyncFromExplicitStaticAbstractInterface : IBindableFromHttpContext + { + static ValueTask IBindableFromHttpContext.BindAsync(HttpContext context, ParameterInfo parameter) + { + return ValueTask.FromResult(new()); + } + } + + private class BindAsyncFromStaticAbstractInterfaceAndBindAsync : IBindableFromHttpContext + { + public BindAsyncFromStaticAbstractInterfaceAndBindAsync(BindAsyncSource boundFrom) + { + BoundFrom = boundFrom; + } + + public BindAsyncSource BoundFrom { get; } + + // Implicit interface implementation + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + return ValueTask.FromResult(new(BindAsyncSource.InterfaceStaticAbstractImplicit)); + } + + // Late-bound pattern based match in RequestDelegateFactory + public static ValueTask BindAsync(HttpContext context) + { + return ValueTask.FromResult(new(BindAsyncSource.Reflection)); + } + } + + private class BindAsyncFromStaticAbstractInterfaceWrongType : IBindableFromHttpContext + { + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + return ValueTask.FromResult(new()); + } + } + + private enum BindAsyncSource + { + Reflection, + InterfaceStaticAbstractImplicit, + InterfaceStaticAbstractExplicit + } + private class MockParameterInfo : ParameterInfo { public MockParameterInfo(Type type, string name) diff --git a/src/Http/samples/MinimalSample/Program.cs b/src/Http/samples/MinimalSample/Program.cs index a15f67986088..27923bed88b3 100644 --- a/src/Http/samples/MinimalSample/Program.cs +++ b/src/Http/samples/MinimalSample/Program.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Http.HttpResults; +using System.Reflection; using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); @@ -63,6 +64,19 @@ }); +app.MapPost("/todos", (TodoBindable todo) => todo); + app.Run(); internal record Todo(int Id, string Title); +public class TodoBindable : IBindableFromHttpContext +{ + public int Id { get; set; } + public string Title { get; set; } + public bool IsComplete { get; set; } + + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + return ValueTask.FromResult(new TodoBindable { Id = 1, Title = "I was bound from IBindableFromHttpContext.BindAsync!" }); + } +} diff --git a/src/Shared/ParameterBindingMethodCache.cs b/src/Shared/ParameterBindingMethodCache.cs index 99418b00261f..eb5750a8fd38 100644 --- a/src/Shared/ParameterBindingMethodCache.cs +++ b/src/Shared/ParameterBindingMethodCache.cs @@ -24,6 +24,7 @@ internal sealed class ParameterBindingMethodCache { private static readonly MethodInfo ConvertValueTaskMethod = typeof(ParameterBindingMethodCache).GetMethod(nameof(ConvertValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ConvertValueTaskOfNullableResultMethod = typeof(ParameterBindingMethodCache).GetMethod(nameof(ConvertValueTaskOfNullableResult), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo BindAsyncMethod = typeof(ParameterBindingMethodCache).GetMethod(nameof(BindAsync), BindingFlags.NonPublic | BindingFlags.Static)!; internal static readonly ParameterExpression TempSourceStringExpr = Expression.Variable(typeof(string), "tempSourceString"); internal static readonly ParameterExpression HttpContextExpr = Expression.Parameter(typeof(HttpContext), "httpContext"); @@ -185,12 +186,18 @@ static bool ValidateReturnType(MethodInfo methodInfo) (Func?, int) Finder(Type nonNullableParameterType) { var hasParameterInfo = true; - // There should only be one BindAsync method with these parameters since C# does not allow overloading on return type. - var methodInfo = GetStaticMethodFromHierarchy(nonNullableParameterType, "BindAsync", new[] { typeof(HttpContext), typeof(ParameterInfo) }, ValidateReturnType); + var methodInfo = GetIBindableFromHttpContextMethod(nonNullableParameterType); + if (methodInfo is null) { - hasParameterInfo = false; - methodInfo = GetStaticMethodFromHierarchy(nonNullableParameterType, "BindAsync", new[] { typeof(HttpContext) }, ValidateReturnType); + // There should only be one BindAsync method with these parameters since C# does not allow overloading on return type. + methodInfo = GetStaticMethodFromHierarchy(nonNullableParameterType, "BindAsync", new[] { typeof(HttpContext), typeof(ParameterInfo) }, ValidateReturnType); + + if (methodInfo is null) + { + hasParameterInfo = false; + methodInfo = GetStaticMethodFromHierarchy(nonNullableParameterType, "BindAsync", new[] { typeof(HttpContext) }, ValidateReturnType); + } } // We're looking for a method with the following signatures: @@ -373,6 +380,26 @@ static bool ValidateReturnType(MethodInfo methodInfo) throw new InvalidOperationException($"No public parameterless constructor found for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'."); } + private static MethodInfo? GetIBindableFromHttpContextMethod(Type type) + { + // Check if parameter is bindable via static abstract method on IBindableFromHttpContext + foreach (var i in type.GetInterfaces()) + { + if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IBindableFromHttpContext<>) && i.GetGenericArguments()[0] == type) + { + return BindAsyncMethod.MakeGenericMethod(type); + } + } + + return null; + } + + private static ValueTask BindAsync(HttpContext httpContext, ParameterInfo parameter) + where TValue : class?, IBindableFromHttpContext + { + return TValue.BindAsync(httpContext, parameter); + } + private MethodInfo? GetStaticMethodFromHierarchy(Type type, string name, Type[] parameterTypes, Func validateReturnType) { bool IsMatch(MethodInfo? method) => method is not null && !method.IsAbstract && validateReturnType(method);