diff --git a/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs index 691aa28146a0..8dd4fb4822fb 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs @@ -2456,7 +2456,7 @@ private void AcceptsProduct_Default([ModelBinder] Product product) } // This will show up as source = unknown - private void AcceptsProduct_Custom([ModelBinder(BinderType = typeof(BodyModelBinder))] Product product) + private void AcceptsProduct_Custom([ModelBinder] Product product) { } @@ -2556,7 +2556,7 @@ private void FromModelBinding(int id) { } - private void FromCustom([ModelBinder(typeof(BodyModelBinder))] int id) + private void FromCustom([ModelBinder] int id) { } diff --git a/src/Mvc/Mvc.Core/src/Filters/MiddlewareFilterOfTAttribute.cs b/src/Mvc/Mvc.Core/src/Filters/MiddlewareFilterOfTAttribute.cs new file mode 100644 index 000000000000..4ea255e3bfc8 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Filters/MiddlewareFilterOfTAttribute.cs @@ -0,0 +1,14 @@ +// 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.Mvc; + +/// +/// A type which configures a middleware pipeline. +public class MiddlewareFilterAttribute : MiddlewareFilterAttribute +{ + /// + /// Instantiates a new instance of . + /// + public MiddlewareFilterAttribute() : base(typeof(T)) { } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinderOfTAttribute.cs b/src/Mvc/Mvc.Core/src/ModelBinderOfTAttribute.cs new file mode 100644 index 000000000000..afa5473d38e2 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinderOfTAttribute.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// A which implements . +/// +/// This is a derived generic variant of the . +/// Ensure that only one instance of either attribute is provided on the target. +/// +public class ModelBinderAttribute : ModelBinderAttribute where TBinder : IModelBinder +{ + /// + /// Initializes a new instance of . + /// + /// + /// Subclass this attribute and set if is not + /// correct for the specified type parameter. + /// + public ModelBinderAttribute() : base(typeof(TBinder)) { } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs index 938af4823e9d..960b2e626002 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs @@ -214,6 +214,25 @@ public static ModelAttributes GetAttributesForParameter(ParameterInfo parameterI private static Type? GetMetadataType(Type type) { - return type.GetCustomAttribute()?.MetadataType; + // GetCustomAttribute will examine the members inheritance chain + // for attributes of a particular type by default. Meaning that + // in the following scenario, the `ModelMetadataType` attribute on + // both the derived _and_ base class will be returned. + // [ModelMetadataType] + // private class BaseViewModel { } + // [ModelMetadataType] + // private class DerivedViewModel : BaseViewModel { } + // To avoid this, we call `GetCustomAttributes` directly + // to avoid examining the inheritance hierarchy. + // See https://source.dot.net/#System.Private.CoreLib/src/System/Attribute.CoreCLR.cs,677 + var modelMetadataTypeAttributes = type.GetCustomAttributes(inherit: false); + try + { + return modelMetadataTypeAttributes?.SingleOrDefault()?.MetadataType; + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException("Only one ModelMetadataType attribute is permitted per type.", e); + } } } diff --git a/src/Mvc/Mvc.Core/src/ModelMetadataTypeOfTAttribute.cs b/src/Mvc/Mvc.Core/src/ModelMetadataTypeOfTAttribute.cs new file mode 100644 index 000000000000..0161f0f3227b --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelMetadataTypeOfTAttribute.cs @@ -0,0 +1,19 @@ +// 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.Mvc; + +/// +/// The type of metadata class that is associated with a data model class. +/// +/// This is a derived generic variant of the +/// which does not allow multiple instances on a single target. +/// Ensure that only one instance of either attribute is provided on the target. +/// +public class ModelMetadataTypeAttribute : ModelMetadataTypeAttribute +{ + /// + /// Initializes a new instance of the class. + /// + public ModelMetadataTypeAttribute() : base(typeof(T)) { } +} diff --git a/src/Mvc/Mvc.Core/src/ProducesOfTAttribute.cs b/src/Mvc/Mvc.Core/src/ProducesOfTAttribute.cs new file mode 100644 index 000000000000..ab2f8c56e0bb --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ProducesOfTAttribute.cs @@ -0,0 +1,18 @@ +// 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.Mvc; + +/// +/// The of object that is going to be written in the response. +/// +/// This is a derived generic variant of the . +/// Ensure that only one instance of either attribute is provided on the target. +/// +public class ProducesAttribute : ProducesAttribute +{ + /// + /// Initializes an instance of . + /// + public ProducesAttribute() : base(typeof(T)) { } +} diff --git a/src/Mvc/Mvc.Core/src/ProducesResponseTypeOfTAttribute.cs b/src/Mvc/Mvc.Core/src/ProducesResponseTypeOfTAttribute.cs new file mode 100644 index 000000000000..0eb6a5010c30 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ProducesResponseTypeOfTAttribute.cs @@ -0,0 +1,24 @@ +// 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.Mvc; + +/// +/// The of object that is going to be written in the response. +public class ProducesResponseTypeAttribute : ProducesResponseTypeAttribute +{ + /// + /// Initializes an instance of . + /// + /// The HTTP response status code. + public ProducesResponseTypeAttribute(int statusCode) : base(typeof(T), statusCode) { } + + /// + /// Initializes an instance of . + /// + /// The HTTP response status code. + /// The content type associated with the response. + /// Additional content types supported by the response. + public ProducesResponseTypeAttribute(int statusCode, string contentType, params string[] additionalContentTypes) + : base(typeof(T), statusCode, contentType, additionalContentTypes) { } +} diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index d88744294890..a6374db5adbb 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -1,5 +1,20 @@ #nullable enable *REMOVED*static Microsoft.AspNetCore.Routing.ControllerLinkGeneratorExtensions.GetUriByAction(this Microsoft.AspNetCore.Routing.LinkGenerator! generator, string! action, string! controller, object? values, string? scheme, Microsoft.AspNetCore.Http.HostString host, Microsoft.AspNetCore.Http.PathString pathBase = default(Microsoft.AspNetCore.Http.PathString), Microsoft.AspNetCore.Http.FragmentString fragment = default(Microsoft.AspNetCore.Http.FragmentString), Microsoft.AspNetCore.Routing.LinkOptions? options = null) -> string? +Microsoft.AspNetCore.Mvc.MiddlewareFilterAttribute +Microsoft.AspNetCore.Mvc.MiddlewareFilterAttribute.MiddlewareFilterAttribute() -> void +Microsoft.AspNetCore.Mvc.ModelBinderAttribute +Microsoft.AspNetCore.Mvc.ModelBinderAttribute.ModelBinderAttribute() -> void +Microsoft.AspNetCore.Mvc.ModelMetadataTypeAttribute +Microsoft.AspNetCore.Mvc.ModelMetadataTypeAttribute.ModelMetadataTypeAttribute() -> void +Microsoft.AspNetCore.Mvc.ProducesAttribute +Microsoft.AspNetCore.Mvc.ProducesAttribute.ProducesAttribute() -> void +Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute +Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute.ProducesResponseTypeAttribute(int statusCode) -> void +Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute.ProducesResponseTypeAttribute(int statusCode, string! contentType, params string![]! additionalContentTypes) -> void +Microsoft.AspNetCore.Mvc.ServiceFilterAttribute +Microsoft.AspNetCore.Mvc.ServiceFilterAttribute.ServiceFilterAttribute() -> void +Microsoft.AspNetCore.Mvc.TypeFilterAttribute +Microsoft.AspNetCore.Mvc.TypeFilterAttribute.TypeFilterAttribute() -> void Microsoft.AspNetCore.Mvc.ValidationProblemDetails.Errors.set -> void static Microsoft.AspNetCore.Routing.ControllerLinkGeneratorExtensions.GetUriByAction(this Microsoft.AspNetCore.Routing.LinkGenerator! generator, string! action, string! controller, object? values, string! scheme, Microsoft.AspNetCore.Http.HostString host, Microsoft.AspNetCore.Http.PathString pathBase = default(Microsoft.AspNetCore.Http.PathString), Microsoft.AspNetCore.Http.FragmentString fragment = default(Microsoft.AspNetCore.Http.FragmentString), Microsoft.AspNetCore.Routing.LinkOptions? options = null) -> string? Microsoft.AspNetCore.Mvc.CreatedResult.CreatedResult() -> void diff --git a/src/Mvc/Mvc.Core/src/ServiceFilterOfTAttribute.cs b/src/Mvc/Mvc.Core/src/ServiceFilterOfTAttribute.cs new file mode 100644 index 000000000000..66e815196209 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ServiceFilterOfTAttribute.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// The of filter to find. +[DebuggerDisplay("ServiceFilter: Type={ServiceType} Order={Order}")] +public class ServiceFilterAttribute : ServiceFilterAttribute where TFilter : IFilterMetadata +{ + /// + /// Instantiates a new instance. + /// + public ServiceFilterAttribute() : base(typeof(TFilter)) { } +} diff --git a/src/Mvc/Mvc.Core/src/TypeFilterOfTAttribute.cs b/src/Mvc/Mvc.Core/src/TypeFilterOfTAttribute.cs new file mode 100644 index 000000000000..549f50774eab --- /dev/null +++ b/src/Mvc/Mvc.Core/src/TypeFilterOfTAttribute.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// The of filter to create. +public class TypeFilterAttribute : TypeFilterAttribute where TFilter : IFilterMetadata +{ + /// + /// Instantiates a new instance. + /// + public TypeFilterAttribute() : base(typeof(TFilter)) { } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/ModelAttributesTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/ModelAttributesTest.cs index df97c310c4e6..73dbd7f6c180 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/ModelAttributesTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/ModelAttributesTest.cs @@ -287,6 +287,18 @@ public void GetAttributesForProperty_WithModelType_IncludesTypeAttributes() attribute => Assert.IsType(attribute)); } + [Fact] + public void GetAttributeForProperty_WithModelType_HandlesMultipleAttributesOnType() + { + // Arrange + var modelType = typeof(InvalidBaseViewModel); + var property = modelType.GetRuntimeProperties().FirstOrDefault(p => p.Name == nameof(BaseModel.RouteValue)); + + // Assert + var exception = Assert.Throws(() => ModelAttributes.GetAttributesForProperty(modelType, property)); + Assert.Equal("Only one ModelMetadataType attribute is permitted per type.", exception.Message); + } + [ClassValidator] private class BaseModel { @@ -322,7 +334,7 @@ private class DerivedModelWithAttributes : BaseModel { } - [ModelMetadataType(typeof(BaseModel))] + [ModelMetadataType] private class BaseViewModel { [Range(0, 10)] @@ -335,7 +347,11 @@ private class BaseViewModel public string RouteValue { get; set; } } - [ModelMetadataType(typeof(DerivedModel))] + [ModelMetadataType] + [ModelMetadataType(typeof(BaseModel))] + private class InvalidBaseViewModel : BaseViewModel { } + + [ModelMetadataType] private class DerivedViewModel : BaseViewModel { [StringLength(2)] @@ -358,7 +374,7 @@ public override bool IsValid(object value) } } - [ModelMetadataType(typeof(MergedAttributesMetadata))] + [ModelMetadataType] private class MergedAttributes { [Required] diff --git a/src/Mvc/test/Mvc.IntegrationTests/BinderTypeBasedModelBinderIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/BinderTypeBasedModelBinderIntegrationTest.cs index d079d50959f8..65e9e9d2c5a6 100644 --- a/src/Mvc/test/Mvc.IntegrationTests/BinderTypeBasedModelBinderIntegrationTest.cs +++ b/src/Mvc/test/Mvc.IntegrationTests/BinderTypeBasedModelBinderIntegrationTest.cs @@ -124,7 +124,7 @@ private class Person public Address Address { get; set; } } - [ModelBinder(BinderType = typeof(AddressModelBinder))] + [ModelBinder] private class Address { public string Street { get; set; } @@ -188,7 +188,7 @@ public async Task BinderTypeOnParameterType_WithData_EmptyPrefix_GetsBound(Bindi private class Person3 { - [ModelBinder(BinderType = typeof(Address3ModelBinder))] + [ModelBinder] public Address3 Address { get; set; } } diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs index 0c9a5445c43c..9dc097fd3db8 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs @@ -6,7 +6,7 @@ namespace ApiExplorerWebSite; [Produces("application/json", Type = typeof(Product))] -[ProducesResponseType(typeof(ErrorInfo), 500)] +[ProducesResponseType(500)] [Route("ApiExplorerResponseTypeOverrideOnAction")] public class ApiExplorerResponseTypeOverrideOnActionController : Controller { @@ -16,7 +16,7 @@ public void GetController() } [HttpGet("Action")] - [Produces(typeof(Customer))] + [Produces] [ProducesResponseType(typeof(ErrorInfoOverride), 500)] // overriding the type specified on the server public object GetAction() { @@ -24,7 +24,7 @@ public object GetAction() } [HttpGet("Action2")] - [ProducesResponseType(typeof(Customer), 200, "text/plain")] + [ProducesResponseType(200, "text/plain")] public object GetActionWithContentTypeOverride() { return null; diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs index 4633fb39651d..eaeea593ae64 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/AntiforgeryController.cs @@ -44,7 +44,7 @@ public string Login(LoginViewModel model) [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] - [TypeFilter(typeof(RedirectAntiforgeryValidationFailedResultFilter))] + [TypeFilter] public string LoginWithRedirectResultFilter(LoginViewModel model) { return "Ok"; diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ContentNegotiationController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ContentNegotiationController.cs index 016e6b97a312..078f61cf10bb 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ContentNegotiationController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/ContentNegotiationController.cs @@ -18,13 +18,13 @@ public User UserInfo() return CreateUser(); } - [Produces(typeof(User))] + [Produces] public IActionResult UserInfo_ProducesWithTypeOnly() { return new ObjectResult(CreateUser()); } - [Produces("application/xml", Type = typeof(User))] + [Produces] public IActionResult UserInfo_ProducesWithTypeAndContentType() { return new ObjectResult(CreateUser()); diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/FiltersController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/FiltersController.cs index dbb4e760e0a2..e733dcf56e7d 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/FiltersController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/FiltersController.cs @@ -15,14 +15,14 @@ public class FiltersController : Controller public IActionResult AlwaysRunResultFiltersCanRunWhenResourceFilterShortCircuit([FromBody] Product product) => throw new Exception("Shouldn't be executed"); - [ServiceFilter(typeof(ServiceActionFilter))] + [ServiceFilter] public IActionResult ServiceFilterTest() => Content("Service filter content"); [TraceResultOutputFilter] public IActionResult TraceResult() => new EmptyResult(); [Route("{culture}/[controller]/[action]")] - [MiddlewareFilter(typeof(LocalizationPipeline))] + [MiddlewareFilter] public IActionResult MiddlewareFilterTest() { return Content($"CurrentCulture:{CultureInfo.CurrentCulture.Name},CurrentUICulture:{CultureInfo.CurrentUICulture.Name}"); diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs index 6c70dad43b62..db052a307555 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RequestScopedServiceController.cs @@ -17,7 +17,7 @@ public string FromConstraint() } [HttpGet] - [TypeFilter(typeof(RequestScopedFilter))] + [TypeFilter] public void FromFilter() { }