diff --git a/src/Microsoft.AspNet.Mvc.Core/AcceptVerbsAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/AcceptVerbsAttribute.cs new file mode 100644 index 0000000000..f86fdfa985 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/AcceptVerbsAttribute.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Specifies what HTTP methods an action supports. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class AcceptVerbsAttribute : Attribute, IActionHttpMethodProvider + { + private readonly IEnumerable _httpMethods; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP method the action supports. + public AcceptVerbsAttribute([NotNull] string method) + : this(new string[] { method }) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP methods the action supports. + public AcceptVerbsAttribute(params string[] methods) + { + // TODO: This assumes that the methods are exactly same as standard Http Methods. + // The Http Abstractions should take care of these. + _httpMethods = methods.Select(method => method.ToUpperInvariant()); + } + + /// + /// Gets the HTTP methods the action supports. + /// + public IEnumerable HttpMethods + { + get + { + return _httpMethods; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionMethodSelectorAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/ActionMethodSelectorAttribute.cs new file mode 100644 index 0000000000..c0dfa95770 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionMethodSelectorAttribute.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc +{ + public interface IActionHttpMethodProvider + { + IEnumerable HttpMethods { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/DefaultActionDiscoveryConventions.cs b/src/Microsoft.AspNet.Mvc.Core/DefaultActionDiscoveryConventions.cs index ccdf85ff6e..531058e7d4 100644 --- a/src/Microsoft.AspNet.Mvc.Core/DefaultActionDiscoveryConventions.cs +++ b/src/Microsoft.AspNet.Mvc.Core/DefaultActionDiscoveryConventions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Reflection; namespace Microsoft.AspNet.Mvc @@ -52,45 +53,127 @@ namespace Microsoft.AspNet.Mvc [NotNull] MethodInfo methodInfo, [NotNull] TypeInfo controllerTypeInfo) { - if (!IsValidMethod(methodInfo)) + if (!IsValidActionMethod(methodInfo)) { return null; } + var actionInfos = GetActionsForMethodsWithCustomAttributes(methodInfo); + if (actionInfos.Any()) + { + return actionInfos; + } + else + { + actionInfos = GetActionsForMethodsWithoutCustomAttributes(methodInfo, controllerTypeInfo); + } + + return actionInfos; + } + + protected virtual bool IsDefaultActionMethod([NotNull] MethodInfo methodInfo) + { + return String.Equals(methodInfo.Name, DefaultMethodName, StringComparison.OrdinalIgnoreCase); + } + + protected virtual bool IsValidActionMethod(MethodInfo method) + { + return + method.IsPublic && + !method.IsAbstract && + !method.IsConstructor && + !method.IsGenericMethod && + + // The SpecialName bit is set to flag members that are treated in a special way by some compilers + // (such as property accessors and operator overloading methods). + !method.IsSpecialName; + } + + public virtual IEnumerable GetSupportedHttpMethods(MethodInfo methodInfo) + { + var supportedHttpMethods = + _supportedHttpMethodsByConvention.FirstOrDefault( + httpMethod => methodInfo.Name.Equals(httpMethod, StringComparison.OrdinalIgnoreCase)); + + if (supportedHttpMethods != null) + { + yield return supportedHttpMethods; + } + } + + private bool HasCustomAttributes(MethodInfo methodInfo) + { + var actionAttributes = GetActionCustomAttributes(methodInfo); + return actionAttributes.HttpMethodProviderAttributes.Any(); + } + + private ActionAttributes GetActionCustomAttributes(MethodInfo methodInfo) + { + var httpMethodConstraints = methodInfo.GetCustomAttributes().OfType(); + return new ActionAttributes() + { + HttpMethodProviderAttributes = httpMethodConstraints + }; + } + + private IEnumerable GetActionsForMethodsWithCustomAttributes(MethodInfo methodInfo) + { + var httpMethodConstraints = GetActionCustomAttributes(methodInfo).HttpMethodProviderAttributes; + if (!httpMethodConstraints.Any()) + { + yield break; + } + + var httpMethods = httpMethodConstraints.SelectMany(x => x.HttpMethods).Distinct().ToArray(); + if (httpMethods.Any()) + { + // Any method which does not follow convention and does not have + // an explicit NoAction attribute is exposed as a method with action name. + yield return new ActionInfo() + { + HttpMethods = httpMethods, + ActionName = methodInfo.Name, + RequireActionNameMatch = true + }; + } + } + + private IEnumerable GetActionsForMethodsWithoutCustomAttributes(MethodInfo methodInfo, TypeInfo controllerTypeInfo) + { var actionInfos = new List(); var httpMethods = GetSupportedHttpMethods(methodInfo); if (httpMethods != null && httpMethods.Any()) { - return new[] { - new ActionInfo() - { - HttpMethods = httpMethods.ToArray(), - ActionName = methodInfo.Name, - RequireActionNameMatch = false, - } - }; + return new[] + { + new ActionInfo() + { + HttpMethods = httpMethods.ToArray(), + ActionName = methodInfo.Name, + RequireActionNameMatch = false, + } + }; } // For Default Method add an action Info with GET, POST Http Method constraints. // Only constraints (out of GET and POST) for which there are no convention based actions available are added. // If there are existing action infos with http constraints for GET and POST, this action info is not added for default method. - if (IsDefaultMethod(methodInfo)) + if (IsDefaultActionMethod(methodInfo)) { var existingHttpMethods = new HashSet(); - foreach (var validMethodName in controllerTypeInfo.DeclaredMethods) + foreach (var declaredMethodInfo in controllerTypeInfo.DeclaredMethods) { - if (!IsValidMethod(validMethodName)) + if (!IsValidActionMethod(declaredMethodInfo) || HasCustomAttributes(declaredMethodInfo)) { continue; } - var methodNames = GetSupportedHttpMethods(validMethodName); - if (methodNames != null ) + httpMethods = GetSupportedHttpMethods(declaredMethodInfo); + if (httpMethods != null) { - existingHttpMethods.UnionWith(methodNames); + existingHttpMethods.UnionWith(httpMethods); } } - var undefinedHttpMethods = _supportedHttpMethodsForDefaultMethod.Except( existingHttpMethods, StringComparer.Ordinal) @@ -116,31 +199,9 @@ namespace Microsoft.AspNet.Mvc return actionInfos; } - public virtual bool IsDefaultMethod([NotNull] MethodInfo methodInfo) + private class ActionAttributes { - return String.Equals(methodInfo.Name, DefaultMethodName, StringComparison.OrdinalIgnoreCase); - } - - public virtual bool IsValidMethod(MethodInfo method) - { - return - method.IsPublic && - !method.IsAbstract && - !method.IsConstructor && - !method.IsGenericMethod && - !method.IsSpecialName; - } - - public virtual IEnumerable GetSupportedHttpMethods(MethodInfo methodInfo) - { - var ret = - _supportedHttpMethodsByConvention.FirstOrDefault( - t => methodInfo.Name.Equals(t, StringComparison.OrdinalIgnoreCase)); - - if (ret != null) - { - yield return ret; - } + public IEnumerable HttpMethodProviderAttributes { get; set; } } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/HttpGetAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/HttpGetAttribute.cs new file mode 100644 index 0000000000..4252ae40d6 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/HttpGetAttribute.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class HttpGetAttribute : Attribute, IActionHttpMethodProvider + { + private static readonly IEnumerable _supportedMethods = new string[] { "GET" }; + + public IEnumerable HttpMethods + { + get { return _supportedMethods; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs index 0e25e2baf8..0cf4c2bcda 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs @@ -22,7 +22,7 @@ namespace Microsoft.AspNet.Mvc IEnumerable globalFilters) { _controllerAssemblyProvider = controllerAssemblyProvider; - _conventions = conventions; + _conventions = conventions; _controllerDescriptorFactory = controllerDescriptorFactory; _parameterDescriptorFactory = parameterDescriptorFactory; var filters = globalFilters ?? Enumerable.Empty(); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs new file mode 100644 index 0000000000..d23cc7312c --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionAttributeTests.cs @@ -0,0 +1,180 @@ +#if NET45 + +using Microsoft.AspNet.Abstractions; +using Microsoft.AspNet.DependencyInjection.NestedProviders; +using Moq; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Core.Test +{ + public class ActionAttributeTests + { + private DefaultActionDiscoveryConventions _actionDiscoveryConventions = new DefaultActionDiscoveryConventions(); + private IControllerDescriptorFactory _controllerDescriptorFactory = new DefaultControllerDescriptorFactory(); + private IParameterDescriptorFactory _parameterDescriptorFactory = new DefaultParameterDescriptorFactory(); + private IEnumerable _controllerAssemblies = new[] { Assembly.GetExecutingAssembly() }; + + [Theory] + [InlineData("GET")] + [InlineData("PUT")] + [InlineData("POST")] + public async Task HttpMethodAttribute_ActionDecoratedWithMultipleHttpMethodAttribute_ORsMultipleHttpMethods(string verb) + { + // Arrange + var requestContext = new RequestContext( + GetHttpContext(verb), + new Dictionary + { + { "controller", "HttpMethodAttributeTests_RestOnly" }, + { "action", "Put" } + }); + + // Act + var result = await InvokeActionSelector(requestContext); + + // Assert + Assert.Equal("Put", result.Name); + } + + [Theory] + [InlineData("GET")] + [InlineData("PUT")] + public async Task HttpMethodAttribute_ActionDecoratedWithHttpMethodAttribute_OverridesConvention(string verb) + { + // Arrange + // Note no action name is passed, hence should return a null action descriptor. + var requestContext = new RequestContext( + GetHttpContext(verb), + new Dictionary + { + { "controller", "HttpMethodAttributeTests_RestOnly" }, + }); + + // Act + var result = await InvokeActionSelector(requestContext); + + // Assert + Assert.Equal(null, result); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + public async Task HttpMethodAttribute_DefaultMethod_IgnoresMethodsWithCustomAttributesAndInvalidMethods(string verb) + { + // Arrange + // Note no action name is passed, hence should return a null action descriptor. + var requestContext = new RequestContext( + GetHttpContext(verb), + new Dictionary + { + { "controller", "HttpMethodAttributeTests_DefaultMethodValidation" }, + }); + + // Act + var result = await InvokeActionSelector(requestContext); + + // Assert + Assert.Equal("Index", result.Name); + } + + private async Task InvokeActionSelector(RequestContext context) + { + return await InvokeActionSelector(context, _actionDiscoveryConventions); + } + + private async Task InvokeActionSelector(RequestContext context, DefaultActionDiscoveryConventions actionDiscoveryConventions) + { + var actionDescriptorProvider = GetActionDescriptorProvider(actionDiscoveryConventions); + var descriptorProvider = + new NestedProviderManager(new[] { actionDescriptorProvider }); + var bindingProvider = new Mock(); + + var defaultActionSelector = new DefaultActionSelector(descriptorProvider, bindingProvider.Object); + return await defaultActionSelector.SelectAsync(context); + } + + private ReflectedActionDescriptorProvider GetActionDescriptorProvider(DefaultActionDiscoveryConventions actionDiscoveryConventions) + { + var controllerAssemblyProvider = new Mock(); + controllerAssemblyProvider.SetupGet(x => x.CandidateAssemblies).Returns(_controllerAssemblies); + return new ReflectedActionDescriptorProvider( + controllerAssemblyProvider.Object, + actionDiscoveryConventions, + _controllerDescriptorFactory, + _parameterDescriptorFactory, + null); + } + + private static HttpContext GetHttpContext(string httpMethod) + { + var request = new Mock(); + var headers = new Mock(); + request.SetupGet(r => r.Headers).Returns(headers.Object); + request.SetupGet(x => x.Method).Returns(httpMethod); + var httpContext = new Mock(); + httpContext.SetupGet(c => c.Request).Returns(request.Object); + return httpContext.Object; + } + + private class CustomActionConvention : DefaultActionDiscoveryConventions + { + public override IEnumerable GetSupportedHttpMethods(MethodInfo methodInfo) + { + if (methodInfo.Name.Equals("PostSomething", StringComparison.OrdinalIgnoreCase)) + { + return new[] { "POST" }; + } + + return null; + } + } + + #region Controller Classes + + private class HttpMethodAttributeTests_DefaultMethodValidationController + { + public void Index() + { + } + + // Method with custom attribute. + [HttpGet] + public void Get() + { } + + // InvalidMethod ( since its private) + private void Post() + { } + } + + private class HttpMethodAttributeTests_RestOnlyController + { + [HttpGet] + [AcceptVerbs("PUT", "POST")] + public void Put() + { + } + + public void Delete() + { + } + + public void Patch() + { + } + } + + private class HttpMethodAttributeTests_DerivedController : HttpMethodAttributeTests_RestOnlyController + { + } + + #endregion Controller Classes + } +} + +#endif \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs index 05df84c664..6a7eb0fb6d 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionSelectionConventionTests.cs @@ -13,7 +13,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test { public class ActionSelectionConventionTests { - private IActionDiscoveryConventions _actionDiscoveryConventions = new DefaultActionDiscoveryConventions(); + private DefaultActionDiscoveryConventions _actionDiscoveryConventions = new DefaultActionDiscoveryConventions(); private IControllerDescriptorFactory _controllerDescriptorFactory = new DefaultControllerDescriptorFactory(); private IParameterDescriptorFactory _parameterDescriptorFactory = new DefaultParameterDescriptorFactory(); private IEnumerable _controllerAssemblies = new[] { Assembly.GetExecutingAssembly() }; @@ -161,7 +161,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test return await InvokeActionSelector(context, _actionDiscoveryConventions); } - private async Task InvokeActionSelector(RequestContext context, IActionDiscoveryConventions actionDiscoveryConventions) + private async Task InvokeActionSelector(RequestContext context, DefaultActionDiscoveryConventions actionDiscoveryConventions) { var actionDescriptorProvider = GetActionDescriptorProvider(actionDiscoveryConventions); var descriptorProvider = @@ -172,7 +172,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test return await defaultActionSelector.SelectAsync(context); } - private ReflectedActionDescriptorProvider GetActionDescriptorProvider(IActionDiscoveryConventions actionDiscoveryConventions) + private ReflectedActionDescriptorProvider GetActionDescriptorProvider(DefaultActionDiscoveryConventions actionDiscoveryConventions) { var controllerAssemblyProvider = new Mock(); controllerAssemblyProvider.SetupGet(x => x.CandidateAssemblies).Returns(_controllerAssemblies);