diff --git a/src/Http/Http.Abstractions/src/AsParametersAttribute.cs b/src/Http/Http.Abstractions/src/AsParametersAttribute.cs new file mode 100644 index 000000000000..5ed7ac1b1aba --- /dev/null +++ b/src/Http/Http.Abstractions/src/AsParametersAttribute.cs @@ -0,0 +1,17 @@ +// 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; + +using System; + +/// +/// Specifies that a route handler delegate's parameter represents a structured parameter list. +/// +[AttributeUsage( + AttributeTargets.Parameter, + Inherited = false, + AllowMultiple = false)] +public sealed class AsParametersAttribute : Attribute +{ +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 54c1f4be51fc..ae9027335d66 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -3,6 +3,8 @@ *REMOVED*Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object? Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.get -> System.IServiceProvider? Microsoft.AspNetCore.Builder.EndpointBuilder.ServiceProvider.set -> void +Microsoft.AspNetCore.Http.AsParametersAttribute +Microsoft.AspNetCore.Http.AsParametersAttribute.AsParametersAttribute() -> void 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! 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 718f747582be..ba4e362f4db3 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core common extension methods for HTTP abstractions, HTTP headers, HTTP request/response, and session state. @@ -13,6 +13,7 @@ + diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index baa1bd46f166..dfe6c5bef609 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -9,6 +9,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Security.Claims; using System.Text; using System.Text.Json; @@ -222,7 +223,10 @@ private static FactoryContext CreateFactoryContext(RequestDelegateFactoryOptions 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); + AddTypeProvidedMetadata(methodInfo, + factoryContext.Metadata, + factoryContext.ServiceProvider, + CollectionsMarshal.AsSpan(factoryContext.Parameters)); // Add method attributes as metadata *after* any inferred metadata so that the attributes hava a higher specificity AddMethodAttributesAsMetadata(methodInfo, factoryContext.Metadata); @@ -424,12 +428,11 @@ private static Expression CreateRouteHandlerInvocationContextBase(FactoryContext return fallbackConstruction; } - private static void AddTypeProvidedMetadata(MethodInfo methodInfo, List metadata, IServiceProvider? services) + private static void AddTypeProvidedMetadata(MethodInfo methodInfo, List metadata, IServiceProvider? services, ReadOnlySpan parameters) { object?[]? invokeArgs = null; // Get metadata from parameter types - var parameters = methodInfo.GetParameters(); foreach (var parameter in parameters) { if (typeof(IEndpointParameterMetadataProvider).IsAssignableFrom(parameter.ParameterType)) @@ -503,10 +506,12 @@ private static Expression[] CreateArguments(ParameterInfo[]? parameters, Factory factoryContext.ArgumentTypes = new Type[parameters.Length]; factoryContext.ArgumentExpressions = new Expression[parameters.Length]; factoryContext.BoxedArgs = new Expression[parameters.Length]; + factoryContext.Parameters = new List(parameters); for (var i = 0; i < parameters.Length; i++) { args[i] = CreateArgument(parameters[i], factoryContext); + // Register expressions containing the boxed and unboxed variants // of the route handler's arguments for use in RouteHandlerInvocationContext // construction and route handler invocation. @@ -599,6 +604,16 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext factoryContext.TrackedParameters.Add(parameter.Name, RequestDelegateFactoryConstants.ServiceAttribute); return BindParameterFromService(parameter, factoryContext); } + else if (parameterCustomAttributes.OfType().Any()) + { + if (parameter is PropertyAsParameterInfo) + { + throw new NotSupportedException( + $"Nested {nameof(AsParametersAttribute)} is not supported and should be used only for handler parameters."); + } + + return BindParameterFromProperties(parameter, factoryContext); + } else if (parameter.ParameterType == typeof(HttpContext)) { return HttpContextExpr; @@ -1221,6 +1236,68 @@ private static Expression GetValueFromProperty(Expression sourceExpression, stri return Expression.Convert(indexExpression, returnType ?? typeof(string)); } + private static Expression BindParameterFromProperties(ParameterInfo parameter, FactoryContext factoryContext) + { + var argumentExpression = Expression.Variable(parameter.ParameterType, $"{parameter.Name}_local"); + var (constructor, parameters) = ParameterBindingMethodCache.FindConstructor(parameter.ParameterType); + + if (constructor is not null && parameters is { Length: > 0 }) + { + // arg_local = new T(....) + + var constructorArguments = new Expression[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var parameterInfo = + new PropertyAsParameterInfo(parameters[i].PropertyInfo, parameters[i].ParameterInfo, factoryContext.NullabilityContext); + constructorArguments[i] = CreateArgument(parameterInfo, factoryContext); + factoryContext.Parameters.Add(parameterInfo); + } + + factoryContext.ParamCheckExpressions.Add( + Expression.Assign( + argumentExpression, + Expression.New(constructor, constructorArguments))); + } + else + { + // arg_local = new T() + // { + // arg_local.Property[0] = expression[0], + // arg_local.Property[n] = expression[n], + // } + + var properties = parameter.ParameterType.GetProperties(); + var bindings = new List(properties.Length); + + for (var i = 0; i < properties.Length; i++) + { + // For parameterless ctor we will init only writable properties. + if (properties[i].CanWrite) + { + var parameterInfo = new PropertyAsParameterInfo(properties[i], factoryContext.NullabilityContext); + bindings.Add(Expression.Bind(properties[i], CreateArgument(parameterInfo, factoryContext))); + factoryContext.Parameters.Add(parameterInfo); + } + } + + var newExpression = constructor is null ? + Expression.New(parameter.ParameterType) : + Expression.New(constructor); + + factoryContext.ParamCheckExpressions.Add( + Expression.Assign( + argumentExpression, + Expression.MemberInit(newExpression, bindings))); + } + + factoryContext.TrackedParameters.Add(parameter.Name!, RequestDelegateFactoryConstants.PropertyAsParameter); + factoryContext.ExtraLocals.Add(argumentExpression); + + return argumentExpression; + } + private static Expression BindParameterFromService(ParameterInfo parameter, FactoryContext factoryContext) { var isOptional = IsOptionalParameter(parameter, factoryContext); @@ -1711,15 +1788,20 @@ private static Expression BindParameterFromBody(ParameterInfo parameter, bool al private static bool IsOptionalParameter(ParameterInfo parameter, FactoryContext factoryContext) { + if (parameter is PropertyAsParameterInfo argument) + { + return argument.IsOptional; + } + // - Parameters representing value or reference types with a default value // under any nullability context are treated as optional. // - Value type parameters without a default value in an oblivious // nullability context are required. // - Reference type parameters without a default value in an oblivious // nullability context are optional. - var nullability = factoryContext.NullabilityContext.Create(parameter); + var nullabilityInfo = factoryContext.NullabilityContext.Create(parameter); return parameter.HasDefaultValue - || nullability.ReadState != NullabilityState.NotNull; + || nullabilityInfo.ReadState != NullabilityState.NotNull; } private static MethodInfo GetMethodInfo(Expression expr) @@ -2000,9 +2082,11 @@ private sealed class FactoryContext public List ContextArgAccess { get; } = new(); public Expression? MethodCall { get; set; } public Type[] ArgumentTypes { get; set; } = Array.Empty(); - public Expression[] ArgumentExpressions { get; set; } = Array.Empty(); + public Expression[] ArgumentExpressions { get; set; } = Array.Empty(); public Expression[] BoxedArgs { get; set; } = Array.Empty(); public List>? Filters { get; init; } + + public List Parameters { get; set; } = new(); } private static class RequestDelegateFactoryConstants @@ -2019,6 +2103,7 @@ private static class RequestDelegateFactoryConstants public const string BodyParameter = "Body (Inferred)"; public const string RouteOrQueryStringParameter = "Route or Query String (Inferred)"; public const string FormFileParameter = "Form File (Inferred)"; + public const string PropertyAsParameter = "As Parameter (Attribute)"; } private static partial class Log diff --git a/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs b/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs index 6e8043b5c4f4..6a1085ca2c4f 100644 --- a/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs +++ b/src/Http/Http.Extensions/test/ParameterBindingMethodCacheTests.cs @@ -359,6 +359,70 @@ public async Task FindBindAsyncMethod_FindsFallbackMethodFromInheritedWhenPrefer Assert.Null(await parseHttpContext(httpContext)); } + [Theory] + [InlineData(typeof(ClassWithParameterlessConstructor))] + [InlineData(typeof(RecordClassParameterlessConstructor))] + [InlineData(typeof(StructWithParameterlessConstructor))] + [InlineData(typeof(RecordStructWithParameterlessConstructor))] + public void FindConstructor_FindsParameterlessConstructor_WhenExplicitlyDeclared(Type type) + { + var cache = new ParameterBindingMethodCache(); + var (constructor, parameters) = cache.FindConstructor(type); + + Assert.NotNull(constructor); + Assert.True(parameters.Length == 0); + } + + [Theory] + [InlineData(typeof(ClassWithDefaultConstructor))] + [InlineData(typeof(RecordClassWithDefaultConstructor))] + public void FindConstructor_FindsDefaultConstructor_WhenNotExplictlyDeclared(Type type) + { + var cache = new ParameterBindingMethodCache(); + var (constructor, parameters) = cache.FindConstructor(type); + + Assert.NotNull(constructor); + Assert.True(parameters.Length == 0); + } + + [Theory] + [InlineData(typeof(ClassWithParameterizedConstructor))] + [InlineData(typeof(RecordClassParameterizedConstructor))] + [InlineData(typeof(StructWithParameterizedConstructor))] + [InlineData(typeof(RecordStructParameterizedConstructor))] + public void FindConstructor_FindsParameterizedConstructor_WhenExplictlyDeclared(Type type) + { + var cache = new ParameterBindingMethodCache(); + var (constructor, parameters) = cache.FindConstructor(type); + + Assert.NotNull(constructor); + Assert.True(parameters.Length == 1); + } + + [Theory] + [InlineData(typeof(StructWithDefaultConstructor))] + [InlineData(typeof(RecordStructWithDefaultConstructor))] + public void FindConstructor_ReturnNullForStruct_WhenNotExplictlyDeclared(Type type) + { + var cache = new ParameterBindingMethodCache(); + var (constructor, parameters) = cache.FindConstructor(type); + + Assert.Null(constructor); + Assert.True(parameters.Length == 0); + } + + [Theory] + [InlineData(typeof(StructWithMultipleConstructors))] + [InlineData(typeof(RecordStructWithMultipleConstructors))] + public void FindConstructor_ReturnNullForStruct_WhenMultipleParameterizedConstructorsDeclared(Type type) + { + var cache = new ParameterBindingMethodCache(); + var (constructor, parameters) = cache.FindConstructor(type); + + Assert.Null(constructor); + Assert.True(parameters.Length == 0); + } + public static TheoryData InvalidTryParseStringTypesData { get @@ -493,6 +557,61 @@ public void FindBindAsyncMethod_IgnoresInvalidBindAsyncIfGoodOneFound(Type type) Assert.NotNull(expression); } + private class ClassWithInternalConstructor + { + internal ClassWithInternalConstructor() + { } + } + private record RecordWithInternalConstructor + { + internal RecordWithInternalConstructor() + { } + } + + [Theory] + [InlineData(typeof(ClassWithInternalConstructor))] + [InlineData(typeof(RecordWithInternalConstructor))] + public void FindConstructor_ThrowsIfNoPublicConstructors(Type type) + { + var cache = new ParameterBindingMethodCache(); + var ex = Assert.Throws(() => cache.FindConstructor(type)); + Assert.Equal($"No public parameterless constructor found for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'.", ex.Message); + } + + [Theory] + [InlineData(typeof(AbstractClass))] + [InlineData(typeof(AbstractRecord))] + public void FindConstructor_ThrowsIfAbstract(Type type) + { + var cache = new ParameterBindingMethodCache(); + var ex = Assert.Throws(() => cache.FindConstructor(type)); + Assert.Equal($"The abstract type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}' is not supported.", ex.Message); + } + + [Theory] + [InlineData(typeof(ClassWithMultipleConstructors))] + [InlineData(typeof(RecordWithMultipleConstructors))] + public void FindConstructor_ThrowsIfMultipleParameterizedConstructors(Type type) + { + var cache = new ParameterBindingMethodCache(); + var ex = Assert.Throws(() => cache.FindConstructor(type)); + Assert.Equal($"Only a single public parameterized constructor is allowed for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'.", ex.Message); + } + + [Theory] + [InlineData(typeof(ClassWithInvalidConstructors))] + [InlineData(typeof(RecordClassWithInvalidConstructors))] + [InlineData(typeof(RecordStructWithInvalidConstructors))] + [InlineData(typeof(StructWithInvalidConstructors))] + public void FindConstructor_ThrowsIfParameterizedConstructorIncludeNoMatchingArguments(Type type) + { + var cache = new ParameterBindingMethodCache(); + var ex = Assert.Throws(() => cache.FindConstructor(type)); + Assert.Equal( + $"The public parameterized constructor must contain only parameters that match the declared public properties for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'.", + ex.Message); + } + enum Choice { One, @@ -1069,6 +1188,165 @@ public static void BindAsync(HttpContext context) => throw new NotImplementedException(); } + public class ClassWithParameterizedConstructor + { + public int Foo { get; set; } + + public ClassWithParameterizedConstructor(int foo) + { + + } + } + + public record RecordClassParameterizedConstructor(int Foo); + + public record struct RecordStructParameterizedConstructor(int Foo); + + public struct StructWithParameterizedConstructor + { + public int Foo { get; set; } + + public StructWithParameterizedConstructor(int foo) + { + Foo = foo; + } + } + + public class ClassWithParameterlessConstructor + { + public ClassWithParameterlessConstructor() + { + } + + public ClassWithParameterlessConstructor(int foo) + { + + } + } + + public record RecordClassParameterlessConstructor + { + public RecordClassParameterlessConstructor() + { + } + + public RecordClassParameterlessConstructor(int foo) + { + + } + } + + public struct StructWithParameterlessConstructor + { + public StructWithParameterlessConstructor() + { + } + + public StructWithParameterlessConstructor(int foo) + { + } + } + + public record struct RecordStructWithParameterlessConstructor + { + public RecordStructWithParameterlessConstructor() + { + } + + public RecordStructWithParameterlessConstructor(int foo) + { + + } + } + + public class ClassWithDefaultConstructor + { } + public record RecordClassWithDefaultConstructor + { } + + public struct StructWithDefaultConstructor + { } + + public record struct RecordStructWithDefaultConstructor + { } + + public struct StructWithMultipleConstructors + { + public StructWithMultipleConstructors(int foo) + { + } + public StructWithMultipleConstructors(int foo, int bar) + { + } + } + + public record struct RecordStructWithMultipleConstructors(int Foo) + { + public RecordStructWithMultipleConstructors(int foo, int bar) + : this(foo) + { + + } + } + + private abstract class AbstractClass { } + + private abstract record AbstractRecord(); + + private class ClassWithMultipleConstructors + { + public ClassWithMultipleConstructors(int foo) + { } + + public ClassWithMultipleConstructors(int foo, int bar) + { } + } + + private record RecordWithMultipleConstructors + { + public RecordWithMultipleConstructors(int foo) + { } + + public RecordWithMultipleConstructors(int foo, int bar) + { } + } + + private class ClassWithInvalidConstructors + { + public int Foo { get; set; } + + public ClassWithInvalidConstructors(int foo, int bar) + { } + } + + private record RecordClassWithInvalidConstructors + { + public int Foo { get; set; } + + public RecordClassWithInvalidConstructors(int foo, int bar) + { } + } + + private struct StructWithInvalidConstructors + { + public int Foo { get; set; } + + public StructWithInvalidConstructors(int foo, int bar) + { + Foo = foo; + } + } + + private record struct RecordStructWithInvalidConstructors + { + public int Foo { get; set; } + + public RecordStructWithInvalidConstructors(int foo, int bar) + { + Foo = foo; + } + } + private class MockParameterInfo : ParameterInfo { public MockParameterInfo(Type type, string name) diff --git a/src/Http/Http.Extensions/test/PropertyAsParameterInfoTests.cs b/src/Http/Http.Extensions/test/PropertyAsParameterInfoTests.cs new file mode 100644 index 000000000000..a4781dd48951 --- /dev/null +++ b/src/Http/Http.Extensions/test/PropertyAsParameterInfoTests.cs @@ -0,0 +1,224 @@ +// 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.Extensions.Tests; + +public class PropertyAsParameterInfoTests +{ + [Fact] + public void Initialization_SetsTypeAndNameFromPropertyInfo() + { + // Arrange & Act + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.NoAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo); + + // Assert + Assert.Equal(propertyInfo.Name, parameterInfo.Name); + Assert.Equal(propertyInfo.PropertyType, parameterInfo.ParameterType); + } + + [Fact] + public void Initialization_WithConstructorArgument_SetsTypeAndNameFromPropertyInfo() + { + // Arrange & Act + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.NoAttribute)); + var parameter = GetParameter(nameof(ArgumentList.DefaultMethod), nameof(ArgumentList.NoAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo, parameter); + + // Assert + Assert.Equal(propertyInfo.Name, parameterInfo.Name); + Assert.Equal(propertyInfo.PropertyType, parameterInfo.ParameterType); + } + + [Fact] + public void PropertyAsParameterInfoTests_ContainsPropertyCustomAttributes() + { + // Arrange + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.WithTestAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo); + + // Act & Assert + Assert.Single(parameterInfo.GetCustomAttributes(typeof(TestAttribute))); + } + + [Fact] + public void PropertyAsParameterInfoTests_WithConstructorArgument_UsesParameterCustomAttributes() + { + // Arrange + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.NoAttribute)); + var parameter = GetParameter(nameof(ArgumentList.DefaultMethod), nameof(ArgumentList.WithTestAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo, parameter); + + // Act & Assert + Assert.Single(parameterInfo.GetCustomAttributes(typeof(TestAttribute))); + } + + [Fact] + public void PropertyAsParameterInfoTests_WithConstructorArgument_FallbackToPropertyCustomAttributes() + { + // Arrange + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.WithTestAttribute)); + var parameter = GetParameter(nameof(ArgumentList.DefaultMethod), nameof(ArgumentList.NoAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo, parameter); + + // Act & Assert + Assert.Single(parameterInfo.GetCustomAttributes(typeof(TestAttribute))); + } + + [Fact] + public void PropertyAsParameterInfoTests_ContainsPropertyCustomAttributesData() + { + // Arrange + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.WithTestAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo); + + // Act + var attributes = parameterInfo.GetCustomAttributesData(); + + // Assert + Assert.Single( + attributes, + a => typeof(TestAttribute).IsAssignableFrom(a.AttributeType)); + } + + [Fact] + public void PropertyAsParameterInfoTests_WithConstructorArgument_MergePropertyAndParameterCustomAttributesData() + { + // Arrange & Act + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.WithTestAttribute)); + var parameter = GetParameter(nameof(ArgumentList.DefaultMethod), nameof(ArgumentList.WithSampleAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo, parameter); + + // Act + var attributes = parameterInfo.GetCustomAttributesData(); + + // Assert + Assert.Single( + parameterInfo.GetCustomAttributesData(), + a => typeof(TestAttribute).IsAssignableFrom(a.AttributeType)); + Assert.Single( + parameterInfo.GetCustomAttributesData(), + a => typeof(SampleAttribute).IsAssignableFrom(a.AttributeType)); + } + + [Fact] + public void PropertyAsParameterInfoTests_WithConstructorArgument_MergePropertyAndParameterCustomAttributes() + { + // Arrange + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.WithTestAttribute)); + var parameter = GetParameter(nameof(ArgumentList.DefaultMethod), nameof(ArgumentList.WithSampleAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo, parameter); + + // Act + var attributes = parameterInfo.GetCustomAttributes(true); + + // Assert + Assert.Single( + attributes, + a => typeof(TestAttribute).IsAssignableFrom(a.GetType())); + Assert.Single( + attributes, + a => typeof(SampleAttribute).IsAssignableFrom(a.GetType())); + } + + [Fact] + public void PropertyAsParameterInfoTests_ContainsPropertyInheritedCustomAttributes() + { + // Arrange & Act + var propertyInfo = GetProperty(typeof(DerivedArgumentList), nameof(DerivedArgumentList.WithTestAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo); + + // Assert + Assert.Single(parameterInfo.GetCustomAttributes(typeof(TestAttribute), true)); + } + + [Fact] + public void PropertyAsParameterInfoTests_DoesNotHaveDefaultValueFromProperty() + { + // Arrange & Act + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.NoAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo); + + // Assert + Assert.False(parameterInfo.HasDefaultValue); + } + + [Fact] + public void PropertyAsParameterInfoTests_WithConstructorArgument_HasDefaultValue() + { + // Arrange & Act + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.NoAttribute)); + var parameter = GetParameter(nameof(ArgumentList.DefaultMethod), "withDefaultValue"); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo, parameter); + + // Assert + Assert.True(parameterInfo.HasDefaultValue); + Assert.NotNull(parameterInfo.DefaultValue); + Assert.IsType(parameterInfo.DefaultValue); + Assert.NotNull(parameterInfo.RawDefaultValue); + Assert.IsType(parameterInfo.RawDefaultValue); + } + + [Fact] + public void PropertyAsParameterInfoTests_WithConstructorArgument_DoesNotHaveDefaultValue() + { + // Arrange & Act + var propertyInfo = GetProperty(typeof(ArgumentList), nameof(ArgumentList.NoAttribute)); + var parameter = GetParameter(nameof(ArgumentList.DefaultMethod), nameof(ArgumentList.NoAttribute)); + var parameterInfo = new PropertyAsParameterInfo(propertyInfo, parameter); + + // Assert + Assert.False(parameterInfo.HasDefaultValue); + } + + private static PropertyInfo GetProperty(Type containerType, string propertyName) + => containerType.GetProperty(propertyName); + + private static ParameterInfo GetParameter(string methodName, string parameterName) + { + var methodInfo = typeof(ArgumentList).GetMethod(methodName); + var parameters = methodInfo.GetParameters(); + return parameters.Single(p => p.Name.Equals(parameterName, StringComparison.OrdinalIgnoreCase)); + } + + private class ArgumentList + { + public int NoAttribute { get; set; } + + [Test] + public virtual int WithTestAttribute { get; set; } + + [Sample] + public int WithSampleAttribute { get; set; } + + public void DefaultMethod( + int noAttribute, + [Test] int withTestAttribute, + [Sample] int withSampleAttribute, + int withDefaultValue = 10) + { } + } + + private class DerivedArgumentList : ArgumentList + { + [DerivedTest] + public override int WithTestAttribute + { + get => base.WithTestAttribute; + set => base.WithTestAttribute = value; + } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, Inherited = true)] + private class SampleAttribute : Attribute + { } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, Inherited = true)] + private class TestAttribute : Attribute + { } + + private class DerivedTestAttribute : TestAttribute + { } +} diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index bb070be027ba..19aed0340a49 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -24,6 +24,7 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -216,6 +217,30 @@ static void TestAction(HttpContext httpContext, [FromRoute] int value) Assert.Equal(originalRouteParam, httpContext.Items["input"]); } + private record ParameterListFromRoute(HttpContext HttpContext, int Value); + + [Fact] + public async Task RequestDelegatePopulatesFromRouteParameterBased_FromParameterList() + { + const string paramName = "value"; + const int originalRouteParam = 42; + + static void TestAction([AsParameters] ParameterListFromRoute args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues[paramName] = originalRouteParam.ToString(NumberFormatInfo.InvariantInfo); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(originalRouteParam, httpContext.Items["input"]); + } + private static void TestOptional(HttpContext httpContext, [FromRoute] int value = 42) { httpContext.Items.Add("input", value); @@ -1491,6 +1516,40 @@ void TestAction([FromQuery] int value) Assert.Equal(originalQueryParam, deserializedRouteParam); } + private record ParameterListFromQuery([FromQuery] int Value); + + [Fact] + public async Task RequestDelegatePopulatesFromQueryParameter_FromParameterList() + { + // QueryCollection is case sensitve, since we now getting + // the parameter name from the Property/Record constructor + // we should match the case here + const string paramName = "Value"; + const int originalQueryParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([AsParameters] ParameterListFromQuery args) + { + deserializedRouteParam = args.Value; + } + + var query = new QueryCollection(new Dictionary() + { + [paramName] = originalQueryParam.ToString(NumberFormatInfo.InvariantInfo) + }); + + var httpContext = CreateHttpContext(); + httpContext.Request.Query = query; + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(originalQueryParam, deserializedRouteParam); + } + [Fact] public async Task RequestDelegatePopulatesFromHeaderParameterBasedOnParameterName() { @@ -1515,6 +1574,36 @@ void TestAction([FromHeader(Name = customHeaderName)] int value) Assert.Equal(originalHeaderParam, deserializedRouteParam); } + private record ParameterListFromHeader([FromHeader(Name = "X-Custom-Header")] int Value); + + [Fact] + public async Task RequestDelegatePopulatesFromHeaderParameter_FromParameterList() + { + const string customHeaderName = "X-Custom-Header"; + const int originalHeaderParam = 42; + + int? deserializedRouteParam = null; + + void TestAction([AsParameters] ParameterListFromHeader args) + { + deserializedRouteParam = args.Value; + } + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers[customHeaderName] = originalHeaderParam.ToString(NumberFormatInfo.InvariantInfo); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(originalHeaderParam, deserializedRouteParam); + } + + private record ParametersListWithImplictFromBody(HttpContext HttpContext, TodoStruct Todo); + + private record ParametersListWithExplictFromBody(HttpContext HttpContext, [FromBody] Todo Todo); + public static object[][] ImplicitFromBodyActions { get @@ -1534,11 +1623,17 @@ void TestImpliedFromBodyStruct(HttpContext httpContext, TodoStruct todo) httpContext.Items.Add("body", todo); } + void TestImpliedFromBodyStruct_ParameterList([AsParameters] ParametersListWithImplictFromBody args) + { + args.HttpContext.Items.Add("body", args.Todo); + } + return new[] { new[] { (Action)TestImpliedFromBody }, new[] { (Action)TestImpliedFromBodyInterface }, new object[] { (Action)TestImpliedFromBodyStruct }, + new object[] { (Action)TestImpliedFromBodyStruct_ParameterList }, }; } } @@ -1552,10 +1647,16 @@ void TestExplicitFromBody(HttpContext httpContext, [FromBody] Todo todo) httpContext.Items.Add("body", todo); } + void TestExplicitFromBody_ParameterList([AsParameters] ParametersListWithExplictFromBody args) + { + args.HttpContext.Items.Add("body", args.Todo); + } + return new[] { new[] { (Action)TestExplicitFromBody }, - }; + new object[] { (Action)TestExplicitFromBody_ParameterList }, + }; } } @@ -2055,6 +2156,122 @@ public static Task BindAsync(HttpContext context, ParameterIn throw new NotImplementedException(); } + public static object[][] BadArgumentListActions + { + get + { + void TestParameterListRecord([AsParameters] BadArgumentListRecord req) { } + void TestParameterListClass([AsParameters] BadArgumentListClass req) { } + void TestParameterListClassWithMutipleConstructors([AsParameters] BadArgumentListClassMultipleCtors req) { } + void TestParameterListAbstractClass([AsParameters] BadAbstractArgumentListClass req) { } + void TestParameterListNoPulicConstructorClass([AsParameters] BadNoPublicConstructorArgumentListClass req) { } + + static string GetMultipleContructorsError(Type type) + => $"Only a single public parameterized constructor is allowed for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'."; + + static string GetAbstractClassError(Type type) + => $"The abstract type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}' is not supported."; + + static string GetNoContructorsError(Type type) + => $"No public parameterless constructor found for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'."; + + static string GetInvalidConstructorError(Type type) + => $"The public parameterized constructor must contain only parameters that match the declared public properties for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'."; + + return new object[][] + { + new object[] { (Action)TestParameterListRecord, GetMultipleContructorsError(typeof(BadArgumentListRecord)) }, + new object[] { (Action)TestParameterListClass, GetInvalidConstructorError(typeof(BadArgumentListClass)) }, + new object[] { (Action)TestParameterListClassWithMutipleConstructors, GetMultipleContructorsError(typeof(BadArgumentListClassMultipleCtors)) }, + new object[] { (Action)TestParameterListAbstractClass, GetAbstractClassError(typeof(BadAbstractArgumentListClass)) }, + new object[] { (Action)TestParameterListNoPulicConstructorClass, GetNoContructorsError(typeof(BadNoPublicConstructorArgumentListClass)) }, + }; + } + } + + [Theory] + [MemberData(nameof(BadArgumentListActions))] + public void BuildRequestDelegateThrowsInvalidOperationExceptionForInvalidParameterListConstructor( + Delegate @delegate, + string errorMessage) + { + var exception = Assert.Throws(() => RequestDelegateFactory.Create(@delegate)); + Assert.Equal(errorMessage, exception.Message); + } + + private record BadArgumentListRecord(int Foo) + { + public BadArgumentListRecord(int foo, int bar) + : this(foo) + { + } + + public int Bar { get; set; } + } + + private class BadNoPublicConstructorArgumentListClass + { + private BadNoPublicConstructorArgumentListClass() + { } + + public int Foo { get; set; } + } + + private abstract class BadAbstractArgumentListClass + { + public int Foo { get; set; } + } + + private class BadArgumentListClass + { + public BadArgumentListClass(int foo, string name) + { + } + + public int Foo { get; set; } + public int Bar { get; set; } + } + + private class BadArgumentListClassMultipleCtors + { + public BadArgumentListClassMultipleCtors(int foo) + { + } + + public BadArgumentListClassMultipleCtors(int foo, int bar) + { + } + + public int Foo { get; set; } + public int Bar { get; set; } + } + + private record NestedArgumentListRecord([AsParameters] object NestedParameterList); + + private class ClassWithParametersConstructor + { + public ClassWithParametersConstructor([AsParameters] object nestedParameterList) + { + NestedParameterList = nestedParameterList; + } + + public object NestedParameterList { get; set; } + } + + [Fact] + public void BuildRequestDelegateThrowsNotSupportedExceptionForNestedParametersList() + { + void TestNestedParameterListRecordOnType([AsParameters] NestedArgumentListRecord req) { } + void TestNestedParameterListRecordOnArgument([AsParameters] ClassWithParametersConstructor req) { } + + Assert.Throws(() => RequestDelegateFactory.Create(TestNestedParameterListRecordOnType)); + Assert.Throws(() => RequestDelegateFactory.Create(TestNestedParameterListRecordOnArgument)); + } + + private record ParametersListWithImplictFromService(HttpContext HttpContext, IMyService MyService); + + private record ParametersListWithExplictFromService(HttpContext HttpContext, [FromService] MyService MyService); + public static object[][] ExplicitFromServiceActions { get @@ -2064,6 +2281,11 @@ void TestExplicitFromService(HttpContext httpContext, [FromService] MyService my httpContext.Items.Add("service", myService); } + void TestExplicitFromService_FromParameterList([AsParameters] ParametersListWithExplictFromService args) + { + args.HttpContext.Items.Add("service", args.MyService); + } + void TestExplicitFromIEnumerableService(HttpContext httpContext, [FromService] IEnumerable myServices) { httpContext.Items.Add("service", myServices.Single()); @@ -2077,6 +2299,7 @@ void TestExplicitMultipleFromService(HttpContext httpContext, [FromService] MySe return new object[][] { new[] { (Action)TestExplicitFromService }, + new object[] { (Action)TestExplicitFromService_FromParameterList }, new[] { (Action>)TestExplicitFromIEnumerableService }, new[] { (Action>)TestExplicitMultipleFromService }, }; @@ -2092,6 +2315,11 @@ void TestImpliedFromService(HttpContext httpContext, IMyService myService) httpContext.Items.Add("service", myService); } + void TestImpliedFromService_FromParameterList([AsParameters] ParametersListWithImplictFromService args) + { + args.HttpContext.Items.Add("service", args.MyService); + } + void TestImpliedIEnumerableFromService(HttpContext httpContext, IEnumerable myServices) { httpContext.Items.Add("service", myServices.Single()); @@ -2105,6 +2333,7 @@ void TestImpliedFromServiceBasedOnContainer(HttpContext httpContext, MyService m return new object[][] { new[] { (Action)TestImpliedFromService }, + new object[] { (Action)TestImpliedFromService_FromParameterList }, new[] { (Action>)TestImpliedIEnumerableFromService }, new[] { (Action)TestImpliedFromServiceBasedOnContainer }, }; @@ -2198,6 +2427,32 @@ void TestAction(HttpContext httpContext) Assert.Same(httpContext, httpContextArgument); } + private record ParametersListWithHttpContext( + HttpContext HttpContext, + ClaimsPrincipal User, + HttpRequest HttpRequest, + HttpResponse HttpResponse); + + [Fact] + public async Task RequestDelegatePopulatesHttpContextParameterWithoutAttribute_FromParameterList() + { + HttpContext? httpContextArgument = null; + + void TestAction([AsParameters] ParametersListWithHttpContext args) + { + httpContextArgument = args.HttpContext; + } + + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Same(httpContext, httpContextArgument); + } + [Fact] public async Task RequestDelegatePassHttpContextRequestAbortedAsCancellationToken() { @@ -2243,6 +2498,27 @@ void TestAction(ClaimsPrincipal user) Assert.Equal(httpContext.User, userArgument); } + [Fact] + public async Task RequestDelegatePassHttpContextUserAsClaimsPrincipal_FromParameterList() + { + ClaimsPrincipal? userArgument = null; + + void TestAction([AsParameters] ParametersListWithHttpContext args) + { + userArgument = args.User; + } + + var httpContext = CreateHttpContext(); + httpContext.User = new ClaimsPrincipal(); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.User, userArgument); + } + [Fact] public async Task RequestDelegatePassHttpContextRequestAsHttpRequest() { @@ -2263,6 +2539,26 @@ void TestAction(HttpRequest httpRequest) Assert.Equal(httpContext.Request, httpRequestArgument); } + [Fact] + public async Task RequestDelegatePassHttpContextRequestAsHttpRequest_FromParameterList() + { + HttpRequest? httpRequestArgument = null; + + void TestAction([AsParameters] ParametersListWithHttpContext args) + { + httpRequestArgument = args.HttpRequest; + } + + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request, httpRequestArgument); + } + [Fact] public async Task RequestDelegatePassesHttpContextRresponseAsHttpResponse() { @@ -2283,6 +2579,26 @@ void TestAction(HttpResponse httpResponse) Assert.Equal(httpContext.Response, httpResponseArgument); } + [Fact] + public async Task RequestDelegatePassesHttpContextRresponseAsHttpResponse_FromParameterList() + { + HttpResponse? httpResponseArgument = null; + + void TestAction([AsParameters] ParametersListWithHttpContext args) + { + httpResponseArgument = args.HttpResponse; + } + + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Response, httpResponseArgument); + } + public static IEnumerable ComplexResult { get @@ -2778,7 +3094,7 @@ public async Task RequestDelegateHandlesQueryParamOptionality(Delegate @delegate var log = Assert.Single(logs); Assert.Equal(LogLevel.Debug, log.LogLevel); Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId); - var expectedType = paramName == "age" ? "int age" : "string name"; + var expectedType = paramName == "age" ? "int age" : $"string name"; Assert.Equal($@"Required parameter ""{expectedType}"" was not provided from route or query string.", log.Message); } else @@ -2850,7 +3166,7 @@ public async Task RequestDelegateHandlesRouteParamOptionality(Delegate @delegate var log = Assert.Single(logs); Assert.Equal(LogLevel.Debug, log.LogLevel); Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId); - var expectedType = paramName == "age" ? "int age" : "string name"; + var expectedType = paramName == "age" ? "int age" : $"string name"; Assert.Equal($@"Required parameter ""{expectedType}"" was not provided from query string.", log.Message); } else @@ -4218,6 +4534,312 @@ void TestAction(IFormFile file) Assert.Equal(400, badHttpRequestException.StatusCode); } + private record struct ParameterListRecordStruct(HttpContext HttpContext, [FromRoute] int Value); + + private record ParameterListRecordClass(HttpContext HttpContext, [FromRoute] int Value); + + private record ParameterListRecordWithoutPositionalParameters + { + public HttpContext? HttpContext { get; set; } + + [FromRoute] + public int Value { get; set; } + } + + private struct ParameterListStruct + { + public HttpContext HttpContext { get; set; } + + [FromRoute] + public int Value { get; set; } + } + + private struct ParameterListMutableStruct + { + public ParameterListMutableStruct() + { + Value = -1; + HttpContext = default!; + } + + public HttpContext HttpContext { get; set; } + + [FromRoute] + public int Value { get; set; } + } + + private class ParameterListStructWithParameterizedContructor + { + public ParameterListStructWithParameterizedContructor(HttpContext httpContext) + { + HttpContext = httpContext; + Value = 42; + } + + public HttpContext HttpContext { get; set; } + + public int Value { get; set; } + } + + private struct ParameterListStructWithMultipleParameterizedContructor + { + public ParameterListStructWithMultipleParameterizedContructor(HttpContext httpContext) + { + HttpContext = httpContext; + Value = 10; + } + + public ParameterListStructWithMultipleParameterizedContructor(HttpContext httpContext, [FromHeader(Name ="Value")] int value) + { + HttpContext = httpContext; + Value = value; + } + + public HttpContext HttpContext { get; set; } + + [FromRoute] + public int Value { get; set; } + } + + private class ParameterListClass + { + public HttpContext? HttpContext { get; set; } + + [FromRoute] + public int Value { get; set; } + } + + private class ParameterListClassWithParameterizedContructor + { + public ParameterListClassWithParameterizedContructor(HttpContext httpContext) + { + HttpContext = httpContext; + Value = 42; + } + + public HttpContext HttpContext { get; set; } + + public int Value { get; set; } + } + + public static object[][] FromParameterListActions + { + get + { + void TestParameterListRecordStruct([AsParameters] ParameterListRecordStruct args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + void TestParameterListRecordClass([AsParameters] ParameterListRecordClass args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + void TestParameterListRecordWithoutPositionalParameters([AsParameters] ParameterListRecordWithoutPositionalParameters args) + { + args.HttpContext!.Items.Add("input", args.Value); + } + + void TestParameterListStruct([AsParameters] ParameterListStruct args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + void TestParameterListMutableStruct([AsParameters] ParameterListMutableStruct args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + void TestParameterListStructWithParameterizedContructor([AsParameters] ParameterListStructWithParameterizedContructor args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + void TestParameterListStructWithMultipleParameterizedContructor([AsParameters] ParameterListStructWithMultipleParameterizedContructor args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + void TestParameterListClass([AsParameters] ParameterListClass args) + { + args.HttpContext!.Items.Add("input", args.Value); + } + + void TestParameterListClassWithParameterizedContructor([AsParameters] ParameterListClassWithParameterizedContructor args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + return new[] + { + new object[] { (Action)TestParameterListRecordStruct }, + new object[] { (Action)TestParameterListRecordClass }, + new object[] { (Action)TestParameterListRecordWithoutPositionalParameters }, + new object[] { (Action)TestParameterListStruct }, + new object[] { (Action)TestParameterListMutableStruct }, + new object[] { (Action)TestParameterListStructWithParameterizedContructor }, + new object[] { (Action)TestParameterListStructWithMultipleParameterizedContructor }, + new object[] { (Action)TestParameterListClass }, + new object[] { (Action)TestParameterListClassWithParameterizedContructor }, + }; + } + } + + [Theory] + [MemberData(nameof(FromParameterListActions))] + public async Task RequestDelegatePopulatesFromParameterList(Delegate action) + { + const string paramName = "value"; + const int originalRouteParam = 42; + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues[paramName] = originalRouteParam.ToString(NumberFormatInfo.InvariantInfo); + + var factoryResult = RequestDelegateFactory.Create(action); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(originalRouteParam, httpContext.Items["input"]); + } + + private record struct SampleParameterList(int Foo); + private record struct AdditionalSampleParameterList(int Bar); + + [Fact] + public async Task RequestDelegatePopulatesFromMultipleParameterLists() + { + const int foo = 1; + const int bar = 2; + + void TestAction(HttpContext context, [AsParameters] SampleParameterList args, [AsParameters] AdditionalSampleParameterList args2) + { + context.Items.Add("foo", args.Foo); + context.Items.Add("bar", args2.Bar); + } + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues[nameof(SampleParameterList.Foo)] = foo.ToString(NumberFormatInfo.InvariantInfo); + httpContext.Request.RouteValues[nameof(AdditionalSampleParameterList.Bar)] = bar.ToString(NumberFormatInfo.InvariantInfo); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(foo, httpContext.Items["foo"]); + Assert.Equal(bar, httpContext.Items["bar"]); + } + + [Fact] + public void RequestDelegateThrowsWhenParameterNameConflicts() + { + void TestAction(HttpContext context, [AsParameters] SampleParameterList args, [AsParameters] SampleParameterList args2) + { + context.Items.Add("foo", args.Foo); + } + var httpContext = CreateHttpContext(); + + var exception = Assert.Throws(() => RequestDelegateFactory.Create(TestAction)); + Assert.Contains("An item with the same key has already been added. Key: Foo", exception.Message); + } + + private class ParameterListWithReadOnlyProperties + { + public ParameterListWithReadOnlyProperties() + { + ReadOnlyValue = 1; + } + + public int Value { get; set; } + + public int ConstantValue => 1; + + public int ReadOnlyValue { get; } + } + + [Fact] + public async Task RequestDelegatePopulatesFromParameterListAndSkipReadOnlyProperties() + { + const int routeParamValue = 42; + var expectedInput = new ParameterListWithReadOnlyProperties() { Value = routeParamValue }; + + void TestAction(HttpContext context, [AsParameters] ParameterListWithReadOnlyProperties args) + { + context.Items.Add("input", args); + } + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues[nameof(ParameterListWithReadOnlyProperties.Value)] = routeParamValue.ToString(NumberFormatInfo.InvariantInfo); + httpContext.Request.RouteValues[nameof(ParameterListWithReadOnlyProperties.ConstantValue)] = routeParamValue.ToString(NumberFormatInfo.InvariantInfo); + httpContext.Request.RouteValues[nameof(ParameterListWithReadOnlyProperties.ReadOnlyValue)] = routeParamValue.ToString(NumberFormatInfo.InvariantInfo); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + var input = Assert.IsType(httpContext.Items["input"]); + Assert.Equal(expectedInput.Value, input.Value); + Assert.Equal(expectedInput.ConstantValue, input.ConstantValue); + Assert.Equal(expectedInput.ReadOnlyValue, input.ReadOnlyValue); + } + + private record ParameterListRecordWitDefaultValue(HttpContext HttpContext, [FromRoute] int Value = 42); + + [Fact] + public async Task RequestDelegatePopulatesFromParameterListRecordUsesDefaultValue() + { + const int expectedValue = 42; + + void TestAction([AsParameters] ParameterListRecordWitDefaultValue args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(expectedValue, httpContext.Items["input"]); + } + + private class ParameterListWitDefaultValue + { + public ParameterListWitDefaultValue(HttpContext httpContext, [FromRoute]int value = 42) + { + HttpContext = httpContext; + Value = value; + } + + public HttpContext HttpContext { get; } + public int Value { get; } + } + + [Fact] + public async Task RequestDelegatePopulatesFromParameterListUsesDefaultValue() + { + const int expectedValue = 42; + + void TestAction([AsParameters] ParameterListWitDefaultValue args) + { + args.HttpContext.Items.Add("input", args.Value); + } + + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(expectedValue, httpContext.Items["input"]); + } + [Fact] public async Task RequestDelegateFactory_InvokesFiltersButNotHandler_OnArgumentError() { @@ -5278,6 +5900,29 @@ public void Create_CombinesDefaultMetadata_AndMetadataFromParameterTypesImplemen Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Parameter }); } + [Fact] + public void Create_CombinesPropertiesAsParameterMetadata_AndTopLevelParameter() + { + // Arrange + var @delegate = ([AsParameters] 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.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Parameter }); + Assert.Contains(result.EndpointMetadata, m => m is ParameterNameMetadata { Name: "param1" }); + Assert.Contains(result.EndpointMetadata, m => m is CustomEndpointMetadata { Source: MetadataSource.Property }); + Assert.Contains(result.EndpointMetadata, m => m is ParameterNameMetadata { Name: nameof(AddsCustomParameterMetadata.Data) }); + } + [Fact] public void Create_CombinesAllMetadata_InCorrectOrder() { @@ -5491,8 +6136,23 @@ public static void PopulateMetadata(EndpointMetadataContext context) public Task ExecuteAsync(HttpContext httpContext) => throw new NotImplementedException(); } + private class AddsCustomParameterMetadataAsProperty : 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.Property }); + } + } + private class AddsCustomParameterMetadata : IEndpointParameterMetadataProvider, IEndpointMetadataProvider { + public AddsCustomParameterMetadataAsProperty? Data { get; set; } + public static void PopulateMetadata(EndpointParameterMetadataContext parameterContext) { parameterContext.EndpointMetadata?.Add(new ParameterNameMetadata { Name = parameterContext.Parameter?.Name }); @@ -5540,7 +6200,8 @@ private enum MetadataSource { Caller, Parameter, - ReturnType + ReturnType, + Property } private class Todo : ITodo diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index abef13ec87c9..dca41b3f155b 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -116,20 +116,18 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string var hasBodyOrFormFileParameter = false; - foreach (var parameter in methodInfo.GetParameters()) + foreach (var parameter in PropertyAsParameterInfo.Flatten(methodInfo.GetParameters(), ParameterBindingMethodCache)) { var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint.RoutePattern, disableInferredBody); - if (parameterDescription is null) + if (parameterDescription is { }) { - continue; - } - - apiDescription.ParameterDescriptions.Add(parameterDescription); + apiDescription.ParameterDescriptions.Add(parameterDescription); - hasBodyOrFormFileParameter |= - parameterDescription.Source == BindingSource.Body || - parameterDescription.Source == BindingSource.FormFile; + hasBodyOrFormFileParameter |= + parameterDescription.Source == BindingSource.Body || + parameterDescription.Source == BindingSource.FormFile; + } } // Get IAcceptsMetadata. 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 4b0491eb95ef..b6bbde313c3d 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/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs index 4c474c953877..0490be15740a 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs @@ -2382,6 +2382,9 @@ private class TestController [FromHeader] public string UserId { get; set; } + [FromServices] + public ITestService TestService { get; set; } + [ModelBinder] public string Comments { get; set; } @@ -2475,6 +2478,9 @@ private class ProductChangeDTO [FromHeader] public string UserId { get; set; } + [FromServices] + public ITestService TestService { get; set; } + public string Comments { get; set; } } diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 5a77ef5f9b55..88f4f06e74f2 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -447,6 +447,48 @@ public void AddsMultipleParameters() #nullable disable + [Fact] + public void AddsMultipleParametersFromParametersAttribute() + { + static void AssertParameters(ApiDescription apiDescription) + { + Assert.Collection( + apiDescription.ParameterDescriptions, + param => + { + Assert.Equal("Foo", param.Name); + Assert.Equal(typeof(int), param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Path, param.Source); + Assert.True(param.IsRequired); + }, + param => + { + Assert.Equal("Bar", param.Name); + Assert.Equal(typeof(int), param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Query, param.Source); + Assert.True(param.IsRequired); + }, + param => + { + Assert.Equal("FromBody", param.Name); + Assert.Equal(typeof(InferredJsonClass), param.Type); + Assert.Equal(typeof(InferredJsonClass), param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Body, param.Source); + Assert.False(param.IsRequired); + } + ); + } + + AssertParameters(GetApiDescription(([AsParameters] ArgumentListClass req) => { })); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListClassWithReadOnlyProperties req) => { })); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListStruct req) => { })); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecord req) => { })); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordStruct req) => { })); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordWithoutPositionalParameters req) => { })); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{foo}")); + AssertParameters(GetApiDescription(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{Foo}")); + } + [Fact] public void TestParameterIsRequired() { @@ -1323,6 +1365,44 @@ public static bool TryParse(string value, out BindAsyncRecord result) => throw new NotImplementedException(); } + private record ArgumentListRecord([FromRoute] int Foo, int Bar, InferredJsonClass FromBody, HttpContext context); + + private record struct ArgumentListRecordStruct([FromRoute] int Foo, int Bar, InferredJsonClass FromBody, HttpContext context); + + private record ArgumentListRecordWithoutAttributes(int Foo, int Bar, InferredJsonClass FromBody, HttpContext context); + + private record ArgumentListRecordWithoutPositionalParameters + { + [FromRoute] + public int Foo { get; set; } + public int Bar { get; set; } + public InferredJsonClass FromBody { get; set; } + public HttpContext Context { get; set; } + } + + private class ArgumentListClass + { + [FromRoute] + public int Foo { get; set; } + public int Bar { get; set; } + public InferredJsonClass FromBody { get; set; } + public HttpContext Context { get; set; } + } + + private class ArgumentListClassWithReadOnlyProperties : ArgumentListClass + { + public int ReadOnly { get; } + } + + private struct ArgumentListStruct + { + [FromRoute] + public int Foo { get; set; } + public int Bar { get; set; } + public InferredJsonClass FromBody { get; set; } + public HttpContext Context { get; set; } + } + private class TestServiceProvider : IServiceProvider { public void Dispose() diff --git a/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs b/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs index 128d4742a8f4..77ecb5b993d7 100644 --- a/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromServicesAttribute.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Mvc; /// -/// Specifies that an action parameter should be bound using the request services. +/// Specifies that a parameter or property should be bound using the request services. /// /// /// In this example an implementation of IProductModelRequestService is registered as a service. @@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Mvc; /// } /// /// -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public class FromServicesAttribute : Attribute, IBindingSourceMetadata, IFromServiceMetadata { /// diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs index e910a21f19e6..3912588804c9 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs @@ -93,6 +93,15 @@ public void OnProvidersExecuting_AddsControllerProperties() Assert.Empty(property.Attributes); }, property => + { + Assert.Equal(nameof(ModelBinderController.Service), property.PropertyName); + Assert.Equal(BindingSource.Services, property.BindingInfo.BindingSource); + Assert.Same(controllerModel, property.Controller); + + var attribute = Assert.Single(property.Attributes); + Assert.IsType(attribute); ; + }, + property => { Assert.Equal(nameof(ModelBinderController.Unbound), property.PropertyName); Assert.Null(property.BindingInfo); @@ -104,7 +113,7 @@ public void OnProvidersExecuting_AddsControllerProperties() public void OnProvidersExecuting_ReadsBindingSourceForPropertiesFromModelMetadata() { // Arrange - var detailsProvider = new BindingSourceMetadataProvider(typeof(string), BindingSource.Services); + var detailsProvider = new BindingSourceMetadataProvider(typeof(string), BindingSource.Special); var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(new[] { detailsProvider }); var typeInfo = typeof(ModelBinderController).GetTypeInfo(); var provider = new TestApplicationModelProvider(new MvcOptions(), modelMetadataProvider); @@ -137,9 +146,18 @@ public void OnProvidersExecuting_ReadsBindingSourceForPropertiesFromModelMetadat }, property => { - Assert.Equal(nameof(ModelBinderController.Unbound), property.PropertyName); + Assert.Equal(nameof(ModelBinderController.Service), property.PropertyName); Assert.Equal(BindingSource.Services, property.BindingInfo.BindingSource); Assert.Same(controllerModel, property.Controller); + + var attribute = Assert.Single(property.Attributes); + Assert.IsType(attribute); + }, + property => + { + Assert.Equal(nameof(ModelBinderController.Unbound), property.PropertyName); + Assert.Equal(BindingSource.Special, property.BindingInfo.BindingSource); + Assert.Same(controllerModel, property.Controller); }); } @@ -1743,6 +1761,9 @@ public class NoFiltersController { } + public interface ITestService + { } + public class ModelBinderController { [FromQuery] @@ -1750,6 +1771,9 @@ public class ModelBinderController public string Unbound { get; set; } + [FromServices] + public ITestService Service { get; set; } + public IFormFile FormFile { get; set; } public IActionResult PostAction([FromQuery] string fromQuery, IFormFileCollection formFileCollection, string unbound) => null; diff --git a/src/Mvc/Mvc.RazorPages/test/ApplicationModels/DefaultPageApplicationModelProviderTest.cs b/src/Mvc/Mvc.RazorPages/test/ApplicationModels/DefaultPageApplicationModelProviderTest.cs index 9a603ca1af2c..41c8e15ca11b 100644 --- a/src/Mvc/Mvc.RazorPages/test/ApplicationModels/DefaultPageApplicationModelProviderTest.cs +++ b/src/Mvc/Mvc.RazorPages/test/ApplicationModels/DefaultPageApplicationModelProviderTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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; @@ -353,6 +353,14 @@ public void OnProvidersExecuting_DiscoversPropertiesFromModel() Assert.Equal(name, property.PropertyName); Assert.NotNull(property.BindingInfo); Assert.Equal(BindingSource.Query, property.BindingInfo.BindingSource); + }, + property => + { + var name = nameof(TestPageModel.TestService); + Assert.Equal(modelType.GetProperty(name), property.PropertyInfo); + Assert.Equal(name, property.PropertyName); + Assert.NotNull(property.BindingInfo); + Assert.Equal(BindingSource.Services, property.BindingInfo.BindingSource); }); } @@ -1058,6 +1066,9 @@ private class PageWithModel : Page public override Task ExecuteAsync() => throw new NotImplementedException(); } + public interface ITestService + { } + [PageModel] private class TestPageModel { @@ -1066,6 +1077,9 @@ private class TestPageModel [FromQuery] public string Property2 { get; set; } + [FromServices] + public ITestService TestService { get; set; } + public void OnGetUser() { } } diff --git a/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageBinderFactoryTest.cs b/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageBinderFactoryTest.cs index 9c67e6856cdb..44146dd56879 100644 --- a/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageBinderFactoryTest.cs +++ b/src/Mvc/Mvc.RazorPages/test/Infrastructure/PageBinderFactoryTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; @@ -838,6 +838,9 @@ public override ValueTask BindModelAsync( } } + private interface ITestService + { } + private class PageModelWithNoBoundProperties : PageModel { } @@ -855,6 +858,9 @@ private class PageWithNoVisibleBoundProperties : Page [FromQuery] protected string FromQuery { get; set; } + [FromServices] + protected ITestService FromService { get; set; } + [FromRoute] public static int FromRoute { get; set; } @@ -869,6 +875,9 @@ private class PageModelWithNoVisibleBoundProperties : PageModel [FromQuery] protected string FromQuery { get; set; } + [FromServices] + protected ITestService FromService { get; set; } + [FromRoute] public static int FromRoute { get; set; } } @@ -898,6 +907,9 @@ private class PageWithProperty : Page [FromForm] public string PropertyWithNoValue { get; set; } + [FromServices] + public ITestService FromService { get; set; } + public override Task ExecuteAsync() => Task.FromResult(0); } @@ -911,6 +923,10 @@ private class PageModelWithProperty : PageModel [FromForm] public string PropertyWithNoValue { get; set; } + + [FromServices] + public ITestService FromService { get; set; } + } private class PageModelWithDefaultValue diff --git a/src/Mvc/test/Mvc.FunctionalTests/RequestServicesTestBase.cs b/src/Mvc/test/Mvc.FunctionalTests/RequestServicesTestBase.cs index 794fb8f94a8c..51dd3eee89e9 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/RequestServicesTestBase.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/RequestServicesTestBase.cs @@ -31,6 +31,7 @@ private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => [InlineData("http://localhost/RequestScopedService/FromView")] [InlineData("http://localhost/RequestScopedService/FromViewComponent")] [InlineData("http://localhost/RequestScopedService/FromActionArgument")] + [InlineData("http://localhost/RequestScopedService/FromProperty")] public async Task RequestServices(string url) { for (var i = 0; i < 2; i++) diff --git a/src/Mvc/test/Mvc.IntegrationTests/ServicesModelBinderIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ServicesModelBinderIntegrationTest.cs index 3dfb12f23e73..67dbcd358f89 100644 --- a/src/Mvc/test/Mvc.IntegrationTests/ServicesModelBinderIntegrationTest.cs +++ b/src/Mvc/test/Mvc.IntegrationTests/ServicesModelBinderIntegrationTest.cs @@ -332,6 +332,7 @@ public async Task BindParameterWithDefaultValueFromService_NoService_BindsToDefa private class Person { + [FromServices] public ITypeActivatorCache Service { get; set; } } @@ -348,8 +349,7 @@ public async Task FromServicesOnPropertyType_WithData_Succeeds(BindingInfo bindi // Similar to a custom IBindingSourceMetadata implementation or [ModelBinder] subclass on a custom service. var metadataProvider = new TestModelMetadataProvider(); metadataProvider - .ForProperty(nameof(Person.Service)) - .BindingDetails(binding => binding.BindingSource = BindingSource.Services); + .ForProperty(nameof(Person.Service)); var testContext = ModelBindingTestHelper.GetTestContext(metadataProvider: metadataProvider); var modelState = testContext.ModelState; diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs index e88bf983b948..6c70dad43b62 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs @@ -45,4 +45,13 @@ public string FromActionArgument([FromServices] RequestIdService requestIdServic { return requestIdService.RequestId; } + + [FromServices] + public RequestIdService RequestIdService { get; set; } + + [HttpGet] + public string FromProperty() + { + return RequestIdService.RequestId; + } } diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index 91cc8230727b..34a81cb70466 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -17,6 +17,7 @@ + diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/OpenApiGenerator.cs index 8c2c76d7d7e0..4283a1ffc981 100644 --- a/src/OpenApi/src/OpenApiGenerator.cs +++ b/src/OpenApi/src/OpenApiGenerator.cs @@ -250,7 +250,8 @@ private static void GenerateDefaultResponses(Dictionary GetOperationTags(MethodInfo methodInfo, EndpointMetadat private List GetOpenApiParameters(MethodInfo methodInfo, EndpointMetadataCollection metadata, RoutePattern pattern, bool disableInferredBody) { - var parameters = methodInfo.GetParameters(); + var parameters = PropertyAsParameterInfo.Flatten(methodInfo.GetParameters(), ParameterBindingMethodCache); var openApiParameters = new List(); foreach (var parameter in parameters) diff --git a/src/OpenApi/test/OpenApiGeneratorTests.cs b/src/OpenApi/test/OpenApiGeneratorTests.cs index 54ed1707dcce..e77cc51ae01e 100644 --- a/src/OpenApi/test/OpenApiGeneratorTests.cs +++ b/src/OpenApi/test/OpenApiGeneratorTests.cs @@ -352,9 +352,49 @@ public void AddsMultipleParameters() Assert.Equal("object", fromBodyParam.Content.First().Value.Schema.Type); Assert.True(fromBodyParam.Required); } - #nullable disable + [Fact] + public void AddsMultipleParametersFromParametersAttribute() + { + static void AssertParameters(OpenApiOperation operation) + { + Assert.Collection( + operation.Parameters, + param => + { + Assert.Equal("Foo", param.Name); + Assert.Equal("integer", param.Schema.Type); + Assert.Equal(ParameterLocation.Path, param.In); + Assert.True(param.Required); + }, + param => + { + Assert.Equal("Bar", param.Name); + Assert.Equal("integer", param.Schema.Type); + Assert.Equal(ParameterLocation.Query, param.In); + Assert.True(param.Required); + }, + param => + { + Assert.Equal("FromBody", param.Name); + var fromBodyParam = operation.RequestBody; + Assert.Equal("object", fromBodyParam.Content.First().Value.Schema.Type); + Assert.False(fromBodyParam.Required); + } + ); + } + + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListClass req) => { })); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListClassWithReadOnlyProperties req) => { })); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListStruct req) => { })); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecord req) => { })); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordStruct req) => { })); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordWithoutPositionalParameters req) => { })); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{foo}")); + AssertParameters(GetOpenApiOperation(([AsParameters] ArgumentListRecordWithoutAttributes req) => { }, "/{Foo}")); + } + [Fact] public void TestParameterIsRequired() { @@ -802,4 +842,42 @@ public static ValueTask BindAsync(HttpContext context, Paramete public static bool TryParse(string value, out BindAsyncRecord result) => throw new NotImplementedException(); } + + private record ArgumentListRecord([FromRoute] int Foo, int Bar, InferredJsonClass FromBody, HttpContext context); + + private record struct ArgumentListRecordStruct([FromRoute] int Foo, int Bar, InferredJsonClass FromBody, HttpContext context); + + private record ArgumentListRecordWithoutAttributes(int Foo, int Bar, InferredJsonClass FromBody, HttpContext context); + + private record ArgumentListRecordWithoutPositionalParameters + { + [FromRoute] + public int Foo { get; set; } + public int Bar { get; set; } + public InferredJsonClass FromBody { get; set; } + public HttpContext Context { get; set; } + } + + private class ArgumentListClass + { + [FromRoute] + public int Foo { get; set; } + public int Bar { get; set; } + public InferredJsonClass FromBody { get; set; } + public HttpContext Context { get; set; } + } + + private class ArgumentListClassWithReadOnlyProperties : ArgumentListClass + { + public int ReadOnly { get; } + } + + private struct ArgumentListStruct + { + [FromRoute] + public int Foo { get; set; } + public int Bar { get; set; } + public InferredJsonClass FromBody { get; set; } + public HttpContext Context { get; set; } + } } diff --git a/src/Shared/ParameterBindingMethodCache.cs b/src/Shared/ParameterBindingMethodCache.cs index 01b5c0895e60..03cd38756a08 100644 --- a/src/Shared/ParameterBindingMethodCache.cs +++ b/src/Shared/ParameterBindingMethodCache.cs @@ -34,6 +34,7 @@ internal sealed class ParameterBindingMethodCache // Since this is shared source, the cache won't be shared between RequestDelegateFactory and the ApiDescriptionProvider sadly :( private readonly ConcurrentDictionary?> _stringMethodCallCache = new(); private readonly ConcurrentDictionary?, int)> _bindAsyncMethodCallCache = new(); + private readonly ConcurrentDictionary _constructorCache = new(); // If IsDynamicCodeSupported is false, we can't use the static Enum.TryParse since there's no easy way for // this code to generate the specific instantiation for any enums used @@ -272,6 +273,102 @@ static bool ValidateReturnType(MethodInfo methodInfo) } } + public (ConstructorInfo?, ConstructorParameter[]) FindConstructor(Type type) + { + static (ConstructorInfo? constructor, ConstructorParameter[] parameters) Finder(Type type) + { + var constructor = GetConstructor(type); + + if (constructor is null || constructor.GetParameters().Length == 0) + { + return (constructor, Array.Empty()); + } + + var properties = type.GetProperties(); + var lookupTable = new Dictionary(properties.Length); + for (var i = 0; i < properties.Length; i++) + { + lookupTable.Add(new ParameterLookupKey(properties[i].Name, properties[i].PropertyType), properties[i]); + } + + // This behavior diverge from the JSON serialization + // since we don't have an attribute, eg. JsonConstructor, + // we need to be very restrictive about the ctor + // and only accept if the parameterized ctor has + // only arguments that we can match (Type and Name) + // with a public property. + + var parameters = constructor.GetParameters(); + var parametersWithPropertyInfo = new ConstructorParameter[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var key = new ParameterLookupKey(parameters[i].Name!, parameters[i].ParameterType); + if (!lookupTable.TryGetValue(key, out var property)) + { + throw new InvalidOperationException( + $"The public parameterized constructor must contain only parameters that match the declared public properties for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'."); + } + + parametersWithPropertyInfo[i] = new ConstructorParameter(parameters[i], property); + } + + return (constructor, parametersWithPropertyInfo); + } + + return _constructorCache.GetOrAdd(type, Finder); + } + + private static ConstructorInfo? GetConstructor(Type type) + { + if (type.IsAbstract) + { + throw new InvalidOperationException($"The abstract type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}' is not supported."); + } + + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + // if only one constructor is declared + // we will use it to try match the properties + if (constructors.Length == 1) + { + return constructors[0]; + } + + // We will try to get the parameterless ctor + // as priority before visit the others + var parameterlessConstructor = constructors.SingleOrDefault(c => c.GetParameters().Length == 0); + if (parameterlessConstructor is not null) + { + return parameterlessConstructor; + } + + // If a parameterized constructors is not found at this point + // we will use a default constructor that is always available + // for value types. + if (type.IsValueType) + { + return null; + } + + // We don't have an attribute, similar to JsonConstructor, to + // disambiguate ctors, so, we will throw if more than one + // ctor is defined without a parameterless constructor. + // Eg.: + // public class X + // { + // public X(int foo) + // public X(int foo, int bar) + // ... + // } + if (parameterlessConstructor is null && constructors.Length > 1) + { + throw new InvalidOperationException($"Only a single public parameterized constructor is allowed for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'."); + } + + throw new InvalidOperationException($"No public parameterless constructor found for type '{TypeNameHelper.GetTypeDisplayName(type, fullName: false)}'."); + } + private MethodInfo? GetStaticMethodFromHierarchy(Type type, string name, Type[] parameterTypes, Func validateReturnType) { bool IsMatch(MethodInfo? method) => method is not null && !method.IsAbstract && validateReturnType(method); @@ -535,4 +632,41 @@ private static bool TryGetNumberStylesTryGetMethod(Type type, [NotNullWhen(true) static async ValueTask ConvertAwaited(ValueTask> typedValueTask) => await typedValueTask; return ConvertAwaited(typedValueTask); } + + private sealed class ParameterLookupKey + { + public ParameterLookupKey(string name, Type type) + { + Name = name; + Type = type; + } + + public string Name { get; } + public Type Type { get; } + + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(Name); + } + + public override bool Equals([NotNullWhen(true)] object? obj) + { + Debug.Assert(obj is ParameterLookupKey); + + var other = (ParameterLookupKey)obj; + return Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); + } + } + + internal sealed class ConstructorParameter + { + public ConstructorParameter(ParameterInfo parameter, PropertyInfo propertyInfo) + { + ParameterInfo = parameter; + PropertyInfo = propertyInfo; + } + + public ParameterInfo ParameterInfo { get; } + public PropertyInfo PropertyInfo { get; } + } } diff --git a/src/Shared/PropertyAsParameterInfo.cs b/src/Shared/PropertyAsParameterInfo.cs new file mode 100644 index 000000000000..31e96c3b8b7b --- /dev/null +++ b/src/Shared/PropertyAsParameterInfo.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Http; + +internal sealed class PropertyAsParameterInfo : ParameterInfo +{ + private readonly PropertyInfo _underlyingProperty; + private readonly ParameterInfo? _constructionParameterInfo; + + private readonly NullabilityInfoContext _nullabilityContext; + private NullabilityInfo? _nullabilityInfo; + + public PropertyAsParameterInfo(PropertyInfo propertyInfo, NullabilityInfoContext? nullabilityContext = null) + { + Debug.Assert(null != propertyInfo); + + AttrsImpl = (ParameterAttributes)propertyInfo.Attributes; + NameImpl = propertyInfo.Name; + MemberImpl = propertyInfo; + ClassImpl = propertyInfo.PropertyType; + + // It is not a real parameter in the delegate, so, + // not defining a real position. + PositionImpl = -1; + + _nullabilityContext = nullabilityContext ?? new NullabilityInfoContext(); + _underlyingProperty = propertyInfo; + } + + public PropertyAsParameterInfo(PropertyInfo property, ParameterInfo parameterInfo, NullabilityInfoContext? nullabilityContext = null) + : this(property, nullabilityContext) + { + _constructionParameterInfo = parameterInfo; + } + + public override bool HasDefaultValue + => _constructionParameterInfo is not null && _constructionParameterInfo.HasDefaultValue; + public override object? DefaultValue + => _constructionParameterInfo is not null ? _constructionParameterInfo.DefaultValue : null; + public override int MetadataToken => _underlyingProperty.MetadataToken; + public override object? RawDefaultValue + => _constructionParameterInfo is not null ? _constructionParameterInfo.RawDefaultValue : null; + + /// + /// Unwraps all parameters that contains and + /// creates a flat list merging the current parameters, not including the + /// parametres that contain a , and all additional + /// parameters detected. + /// + /// List of parameters to be flattened. + /// An instance of the method cache class. + /// Flat list of parameters. + [UnconditionalSuppressMessage("Trimmer", "IL2075", Justification = "PropertyAsParameterInfo.Flatten requires unreferenced code.")] + public static ReadOnlySpan Flatten(ParameterInfo[] parameters, ParameterBindingMethodCache cache) + { + ArgumentNullException.ThrowIfNull(nameof(parameters)); + ArgumentNullException.ThrowIfNull(nameof(cache)); + + if (parameters.Length == 0) + { + return Array.Empty(); + } + + List? flattenedParameters = null; + NullabilityInfoContext? nullabilityContext = null; + + for (var i = 0; i < parameters.Length; i++) + { + if (parameters[i].CustomAttributes.Any(a => a.AttributeType == typeof(AsParametersAttribute))) + { + // Initialize the list with all parameter already processed + // to keep the same parameter ordering + flattenedParameters ??= new(parameters[0..i]); + nullabilityContext ??= new(); + + var (constructor, constructorParameters) = cache.FindConstructor(parameters[i].ParameterType); + if (constructor is not null && constructorParameters is { Length: > 0 }) + { + foreach (var constructorParameter in constructorParameters) + { + flattenedParameters.Add( + new PropertyAsParameterInfo( + constructorParameter.PropertyInfo, + constructorParameter.ParameterInfo, + nullabilityContext)); + } + } + else + { + var properties = parameters[i].ParameterType.GetProperties(); + + foreach (var property in properties) + { + if (property.CanWrite) + { + flattenedParameters.Add(new PropertyAsParameterInfo(property, nullabilityContext)); + } + } + } + } + else if (flattenedParameters is not null) + { + flattenedParameters.Add(parameters[i]); + } + } + + return flattenedParameters is not null ? CollectionsMarshal.AsSpan(flattenedParameters) : parameters.AsSpan(); + } + + public override object[] GetCustomAttributes(Type attributeType, bool inherit) + { + var attributes = _constructionParameterInfo?.GetCustomAttributes(attributeType, inherit); + + if (attributes == null || attributes is { Length: 0 }) + { + attributes = _underlyingProperty.GetCustomAttributes(attributeType, inherit); + } + + return attributes; + } + + public override object[] GetCustomAttributes(bool inherit) + { + var constructorAttributes = _constructionParameterInfo?.GetCustomAttributes(inherit); + + if (constructorAttributes == null || constructorAttributes is { Length: 0 }) + { + return _underlyingProperty.GetCustomAttributes(inherit); + } + + var propertyAttributes = _underlyingProperty.GetCustomAttributes(inherit); + + // Since the constructors attributes should take priority we will add them first, + // as we usually call it as First() or FirstOrDefault() in the argument creation + var mergedAttributes = new object[constructorAttributes.Length + propertyAttributes.Length]; + Array.Copy(constructorAttributes, mergedAttributes, constructorAttributes.Length); + Array.Copy(propertyAttributes, 0, mergedAttributes, constructorAttributes.Length, propertyAttributes.Length); + + return mergedAttributes; + } + + public override IList GetCustomAttributesData() + { + var attributes = new List( + _constructionParameterInfo?.GetCustomAttributesData() ?? Array.Empty()); + attributes.AddRange(_underlyingProperty.GetCustomAttributesData()); + + return attributes.AsReadOnly(); + } + + public override Type[] GetOptionalCustomModifiers() + => _underlyingProperty.GetOptionalCustomModifiers(); + + public override Type[] GetRequiredCustomModifiers() + => _underlyingProperty.GetRequiredCustomModifiers(); + + public override bool IsDefined(Type attributeType, bool inherit) + { + return (_constructionParameterInfo is not null && _constructionParameterInfo.IsDefined(attributeType, inherit)) || + _underlyingProperty.IsDefined(attributeType, inherit); + } + + public new bool IsOptional => HasDefaultValue || NullabilityInfo.ReadState != NullabilityState.NotNull; + + public NullabilityInfo NullabilityInfo + => _nullabilityInfo ??= _constructionParameterInfo is not null ? + _nullabilityContext.Create(_constructionParameterInfo) : + _nullabilityContext.Create(_underlyingProperty); +}