diff --git a/Mvc.sln b/Mvc.sln index 52f976e5b0..01b51cf2ce 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 -VisualStudioVersion = 14.0.22416.0 +VisualStudioVersion = 14.0.22303.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{DAAE4C74-D06F-4874-A166-33305D2643CE}" EndProject @@ -116,6 +116,8 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "LoggingWebSite", "test\WebS EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ErrorPageMiddlewareWebSite", "test\WebSites\ErrorPageMiddlewareWebSite\ErrorPageMiddlewareWebSite.kproj", "{AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ActionConstraintsWebSite", "test\WebSites\ActionConstraintsWebSite\ActionConstraintsWebSite.kproj", "{AF210F69-9D31-43AF-AC3A-CD366E252218}" +EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "CustomRouteWebSite", "test\WebSites\CustomRouteWebSite\CustomRouteWebSite.kproj", "{364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}" EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ResponseCacheWebSite", "test\WebSites\ResponseCacheWebSite\ResponseCacheWebSite.kproj", "{BDEEBE09-C0C4-433C-B0B8-8478C9776996}" @@ -644,6 +646,18 @@ Global {AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}.Release|Mixed Platforms.Build.0 = Release|Any CPU {AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}.Release|x86.ActiveCfg = Release|Any CPU {AD545A5B-2BA5-4314-88AC-FC2ACF2CC718}.Release|x86.Build.0 = Release|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Debug|x86.Build.0 = Debug|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Any CPU.Build.0 = Release|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|x86.ActiveCfg = Release|Any CPU + {AF210F69-9D31-43AF-AC3A-CD366E252218}.Release|x86.Build.0 = Release|Any CPU {364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}.Debug|Any CPU.Build.0 = Debug|Any CPU {364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -724,6 +738,7 @@ Global {0A6BB4C0-48D3-4E7F-952B-B8917345E075} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {0AD78AB5-D67C-49BC-81B1-0C51BFA82B5E} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {AD545A5B-2BA5-4314-88AC-FC2ACF2CC718} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} + {AF210F69-9D31-43AF-AC3A-CD366E252218} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {364EC3C6-C9DB-45E0-A0F2-1EE61E4B429B} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {BDEEBE09-C0C4-433C-B0B8-8478C9776996} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} EndGlobalSection diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionConstraints/ConsumesAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/ActionConstraints/ConsumesAttribute.cs new file mode 100644 index 0000000000..6b61e23d38 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionConstraints/ConsumesAttribute.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Specifies the allowed content types which can be used to select the action based on request's content-type. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class ConsumesAttribute : Attribute, IResourceFilter, IConsumesActionConstraint + { + public static readonly int ConsumesActionConstraintOrder = 200; + + /// + /// Creates a new instance of . + /// + public ConsumesAttribute([NotNull] string contentType, params string[] otherContentTypes) + { + ContentTypes = GetContentTypes(contentType, otherContentTypes); + } + + // The value used is a non default value so that it avoids getting mixed with other action constraints + // with default order. + /// + int IActionConstraint.Order { get; } = ConsumesActionConstraintOrder; + + /// + public IList ContentTypes { get; set; } + + /// + public void OnResourceExecuting([NotNull] ResourceExecutingContext context) + { + // Only execute if the current filter is the one which is closest to the action. + // Ignore all other filters. This is to ensure we have a overriding behavior. + if (IsApplicable(context.ActionDescriptor)) + { + MediaTypeHeaderValue requestContentType = null; + MediaTypeHeaderValue.TryParse(context.HttpContext.Request.ContentType, out requestContentType); + + // Only execute if this is the last filter before calling the action. + // This ensures that we only run the filter which is closest to the action. + if (requestContentType != null && + !ContentTypes.Any(contentType => contentType.IsSubsetOf(requestContentType))) + { + context.Result = new UnsupportedMediaTypeResult(); + } + } + } + + /// + public void OnResourceExecuted([NotNull] ResourceExecutedContext context) + { + } + + public bool Accept(ActionConstraintContext context) + { + // If this constraint is not closest to the action, it will be skipped. + if (!IsApplicable(context.CurrentCandidate.Action)) + { + // Since the constraint is to be skipped, returning true here + // will let the current candidate ignore this constraint and will + // be selected based on other constraints for this action. + return true; + } + + MediaTypeHeaderValue requestContentType = null; + MediaTypeHeaderValue.TryParse(context.RouteContext.HttpContext.Request.ContentType, out requestContentType); + + // If the request content type is null we need to act like pass through. + // In case there is a single candidate with a constraint it should be selected. + // If there are multiple actions with consumes action constraints this should result in ambiguous exception + // unless there is another action without a consumes constraint. + if (requestContentType == null) + { + var isActionWithoutConsumeConstraintPresent = context.Candidates.Any( + candidate => candidate.Constraints == null || + !candidate.Constraints.Any(constraint => constraint is IConsumesActionConstraint)); + + return !isActionWithoutConsumeConstraintPresent; + } + + if (ContentTypes.Any(c => c.IsSubsetOf(requestContentType))) + { + return true; + } + + var firstCandidate = context.Candidates[0]; + if (firstCandidate != context.CurrentCandidate) + { + // If the current candidate is not same as the first candidate, + // we need not probe other candidates to see if they apply. + // Only the first candidate is allowed to probe other candidates and based on the result select itself. + return false; + } + + // Run the matching logic for all IConsumesActionConstraints we can find, and see what matches. + // 1). If we have a unique best match, then only that constraint should return true. + // 2). If we have multiple matches, then all constraints that match will return true + // , resulting in ambiguity(maybe). + // 3). If we have no matches, then we choose the first constraint to return true.It will later return a 415 + foreach (var candidate in context.Candidates) + { + if (candidate == firstCandidate) + { + continue; + } + + var tempContext = new ActionConstraintContext() + { + Candidates = context.Candidates, + RouteContext = context.RouteContext, + CurrentCandidate = candidate + }; + + if (candidate.Constraints == null || candidate.Constraints.Count() == 0 || + candidate.Constraints.Any(constraint => constraint is IConsumesActionConstraint && + constraint.Accept(tempContext))) + { + // There is someone later in the chain which can handle the request. + // end the process here. + return false; + } + } + + // There is no one later in the chain that can handle this content type return a false positive so that + // later we can detect and return a 415. + return true; + } + + private bool IsApplicable(ActionDescriptor actionDescriptor) + { + // If there are multiple IConsumeActionConstraints which are defined at the class and + // at the action level, the one closest to the action overrides the others. To ensure this + // we take advantage of the fact that ConsumesAttribute is both an IActionFilter and an + // IConsumeActionConstraint. Since filterdescriptor collection is ordered (the last filter is the one + // closest to the action), we apply this constraint only if there is no IConsumeActionConstraint after this. + return actionDescriptor.FilterDescriptors.Last( + filter => filter.Filter is IConsumesActionConstraint).Filter == this; + + } + + private List GetContentTypes(string firstArg, string[] args) + { + var contentTypes = new List(); + contentTypes.Add(MediaTypeHeaderValue.Parse(firstArg)); + foreach (var item in args) + { + var contentType = MediaTypeHeaderValue.Parse(item); + contentTypes.Add(contentType); + } + + return contentTypes; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionConstraints/IConsumesActionConstraint.cs b/src/Microsoft.AspNet.Mvc.Core/ActionConstraints/IConsumesActionConstraint.cs new file mode 100644 index 0000000000..5f5825b32f --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionConstraints/IConsumesActionConstraint.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Mvc +{ + /// + /// An constraint that identifies a type which can be used to select an action + /// based on incoming request. + /// + public interface IConsumesActionConstraint : IActionConstraint + { + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/UnsupportedMediaTypeResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/UnsupportedMediaTypeResult.cs new file mode 100644 index 0000000000..e84fbc5379 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/UnsupportedMediaTypeResult.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Mvc +{ + /// + /// A that when + /// executed will produce a UnsupportedMediaType (415) response. + /// + public class UnsupportedMediaTypeResult : HttpStatusCodeResult + { + /// + /// Creates a new instance of . + /// + public UnsupportedMediaTypeResult() : base(415) + { + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ConsumesAttributeTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ConsumesAttributeTests.cs new file mode 100644 index 0000000000..be302c3542 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ConsumesAttributeTests.cs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNet.Http.Core; +using Microsoft.AspNet.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc +{ + public class ConsumesAttributeTests + { + [Theory] + [InlineData("application")] + [InlineData("")] + [InlineData(null)] + public void Constructor_ForInvalidContentType_Throws(string contentType) + { + // Arrange + var expectedMessage = string.Format("Invalid value '{0}'.", contentType ?? ""); + + // Act & Assert + var exception = Assert.Throws(() => new ConsumesAttribute(contentType)); + Assert.Equal(expectedMessage, exception.Message); + } + + [Theory] + [InlineData("application/json")] + [InlineData("application/json;Parameter1=12")] + [InlineData("text/xml")] + public void Accept_MatchesForMachingRequestContentType(string contentType) + { + // Arrange + var constraint = new ConsumesAttribute("application/json", "text/xml"); + var action = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint, FilterScope.Action) } + }; + + var context = new ActionConstraintContext(); + context.Candidates = new List() + { + new ActionSelectorCandidate(action, new [] { constraint }), + }; + + context.CurrentCandidate = context.Candidates[0]; + context.RouteContext = CreateRouteContext(contentType: contentType); + + // Act & Assert + Assert.True(constraint.Accept(context)); + } + + [Fact] + public void Accept_TheFirstCandidateReturnsFalse_IfALaterOneMatches() + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json", "text/xml"); + var action1 = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint1, FilterScope.Action) } + }; + + var constraint2 = new Mock(); + var action2 = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint2.Object, FilterScope.Action) } + }; + + constraint2.Setup(o => o.Accept(It.IsAny())) + .Returns(true); + + var context = new ActionConstraintContext(); + context.Candidates = new List() + { + new ActionSelectorCandidate(action1, new [] { constraint1 }), + new ActionSelectorCandidate(action2, new [] { constraint2.Object }), + }; + + context.CurrentCandidate = context.Candidates[0]; + context.RouteContext = CreateRouteContext(contentType: "application/custom"); + + // Act & Assert + Assert.False(constraint1.Accept(context)); + } + + [Theory] + [InlineData("application/custom")] + [InlineData("")] + [InlineData(null)] + public void Accept_ForNoMatchingCandidates_SelectsTheFirstCandidate(string contentType) + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json", "text/xml"); + var action1 = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint1, FilterScope.Action) } + }; + + var constraint2 = new Mock(); + var action2 = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint2.Object, FilterScope.Action) } + }; + + constraint2.Setup(o => o.Accept(It.IsAny())) + .Returns(false); + + var context = new ActionConstraintContext(); + context.Candidates = new List() + { + new ActionSelectorCandidate(action1, new [] { constraint1 }), + new ActionSelectorCandidate(action2, new [] { constraint2.Object }), + }; + + context.CurrentCandidate = context.Candidates[0]; + context.RouteContext = CreateRouteContext(contentType: contentType); + + // Act & Assert + Assert.True(constraint1.Accept(context)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Accept_ForNoRequestType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json"); + var actionWithConstraint = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint1, FilterScope.Action) } + }; + + var constraint2 = new ConsumesAttribute("text/xml"); + var actionWithConstraint2 = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint2, FilterScope.Action) } + }; + + var actionWithoutConstraint = new ActionDescriptor(); + + var context = new ActionConstraintContext(); + context.Candidates = new List() + { + new ActionSelectorCandidate(actionWithConstraint, new [] { constraint1 }), + new ActionSelectorCandidate(actionWithConstraint2, new [] { constraint2 }), + new ActionSelectorCandidate(actionWithoutConstraint, new List()), + }; + + context.RouteContext = CreateRouteContext(contentType: contentType); + + // Act & Assert + context.CurrentCandidate = context.Candidates[0]; + Assert.False(constraint1.Accept(context)); + context.CurrentCandidate = context.Candidates[1]; + Assert.False(constraint2.Accept(context)); + } + + [Theory] + [InlineData("application/xml")] + [InlineData("application/custom")] + [InlineData("invalid/invalid")] + public void Accept_UnrecognizedMediaType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + { + // Arrange + var actionWithoutConstraint = new ActionDescriptor(); + var constraint1 = new ConsumesAttribute("application/json"); + var actionWithConstraint = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint1, FilterScope.Action) } + }; + + var constraint2 = new ConsumesAttribute("text/xml"); + var actionWithConstraint2 = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint2, FilterScope.Action) } + }; + + var context = new ActionConstraintContext(); + context.Candidates = new List() + { + new ActionSelectorCandidate(actionWithConstraint, new [] { constraint1 }), + new ActionSelectorCandidate(actionWithConstraint2, new [] { constraint2 }), + new ActionSelectorCandidate(actionWithoutConstraint, new List()), + }; + + context.RouteContext = CreateRouteContext(contentType: contentType); + + // Act & Assert + context.CurrentCandidate = context.Candidates[0]; + Assert.False(constraint1.Accept(context)); + + context.CurrentCandidate = context.Candidates[1]; + Assert.False(constraint2.Accept(context)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Accept_ForNoRequestType_ReturnsTrueForAllConstraints(string contentType) + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json"); + var actionWithConstraint = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint1, FilterScope.Action) } + }; + + var constraint2 = new ConsumesAttribute("text/xml"); + var actionWithConstraint2 = new ActionDescriptor() + { + FilterDescriptors = + new List() { new FilterDescriptor(constraint2, FilterScope.Action) } + }; + + var actionWithoutConstraint = new ActionDescriptor(); + + var context = new ActionConstraintContext(); + context.Candidates = new List() + { + new ActionSelectorCandidate(actionWithConstraint, new [] { constraint1 }), + new ActionSelectorCandidate(actionWithConstraint2, new [] { constraint2 }), + }; + + context.RouteContext = CreateRouteContext(contentType: contentType); + + // Act & Assert + context.CurrentCandidate = context.Candidates[0]; + Assert.True(constraint1.Accept(context)); + context.CurrentCandidate = context.Candidates[1]; + Assert.True(constraint2.Accept(context)); + } + + [Theory] + [InlineData("application/xml")] + [InlineData("application/custom")] + public void OnResourceExecuting_ForNoContentTypeMatch_SetsUnsupportedMediaTypeResult(string contentType) + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = contentType; + var consumesFilter = new ConsumesAttribute("application/json"); + var actionWithConstraint = new ActionDescriptor() + { + ActionConstraints = new List() { consumesFilter }, + FilterDescriptors = + new List() { new FilterDescriptor(consumesFilter, FilterScope.Action) } + }; + var actionContext = new ActionContext(httpContext, new RouteData(), actionWithConstraint); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, new[] { consumesFilter }); + + // Act + consumesFilter.OnResourceExecuting(resourceExecutingContext); + + // Assert + Assert.NotNull(resourceExecutingContext.Result); + Assert.IsType(resourceExecutingContext.Result); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void OnResourceExecuting_NullOrEmptyRequestContentType_IsNoOp(string contentType) + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = contentType; + var consumesFilter = new ConsumesAttribute("application/json"); + var actionWithConstraint = new ActionDescriptor() + { + ActionConstraints = new List() { consumesFilter }, + FilterDescriptors = + new List() { new FilterDescriptor(consumesFilter, FilterScope.Action) } + }; + var actionContext = new ActionContext(httpContext, new RouteData(), actionWithConstraint); + + var resourceExecutingContext = new ResourceExecutingContext(actionContext, new[] { consumesFilter }); + + // Act + consumesFilter.OnResourceExecuting(resourceExecutingContext); + + // Assert + Assert.Null(resourceExecutingContext.Result); + } + + [Theory] + [InlineData("application/xml")] + [InlineData("application/json")] + public void OnResourceExecuting_ForAContentTypeMatch_IsNoOp(string contentType) + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = contentType; + var consumesFilter = new ConsumesAttribute("application/json", "application/xml"); + var actionWithConstraint = new ActionDescriptor() + { + ActionConstraints = new List() { consumesFilter }, + FilterDescriptors = + new List() { new FilterDescriptor(consumesFilter, FilterScope.Action) } + }; + var actionContext = new ActionContext(httpContext, new RouteData(), actionWithConstraint); + var resourceExecutingContext = new ResourceExecutingContext(actionContext, new[] { consumesFilter }); + + // Act + consumesFilter.OnResourceExecuting(resourceExecutingContext); + + // Assert + Assert.Null(resourceExecutingContext.Result); + } + + private static RouteContext CreateRouteContext(string contentType = null, object routeValues = null) + { + var httpContext = new DefaultHttpContext(); + if (contentType != null) + { + httpContext.Request.ContentType = contentType; + } + + var routeContext = new RouteContext(httpContext); + routeContext.RouteData = new RouteData(); + + foreach (var kvp in new RouteValueDictionary(routeValues)) + { + routeContext.RouteData.Values.Add(kvp.Key, kvp.Value); + } + + return routeContext; + } + + public interface ITestConsumeConstraint : IConsumesActionConstraint, IResourceFilter + { + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ConsumesAttributeTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ConsumesAttributeTests.cs new file mode 100644 index 0000000000..c1739bb4d7 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ConsumesAttributeTests.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Serialization; +using ActionConstraintsWebSite; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class ConsumesAttributeTests + { + private readonly IServiceProvider _provider = TestHelper.CreateServices("ActionConstraintsWebSite"); + private readonly Action _app = new Startup().Configure; + + [Fact] + public async Task NoRequestContentType_SelectsActionWithoutConstraint() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_Company/CreateProduct"); + + // Act + var response = await client.SendAsync(request); + var product = JsonConvert.DeserializeObject( + await response.Content.ReadAsStringAsync()); + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Null(product); + } + + [Fact] + public async Task NoRequestContentType_Throws_IfMultipleActionsWithConstraints() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_AmbiguousActions/CreateProduct"); + + // Act + var response = await client.SendAsync(request); + var exception = response.GetServerException(); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal(typeof(AmbiguousActionException).FullName, exception.ExceptionType); + Assert.Equal( + "Multiple actions matched. The following actions matched route data and had all constraints "+ + "satisfied:____ActionConstraintsWebSite.ConsumesAttribute_NoFallBackActionController."+ + "CreateProduct__ActionConstraintsWebSite.ConsumesAttribute_NoFallBackActionController.CreateProduct", + exception.ExceptionMessage); + } + + [Fact] + public async Task NoRequestContentType_Selects_IfASingleActionWithConstraintIsPresent() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_PassThrough/CreateProduct"); + + // Act + var response = await client.SendAsync(request); + var product = JsonConvert.DeserializeObject( + await response.Content.ReadAsStringAsync()); + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Null(product); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task Selects_Action_BasedOnRequestContentType(string requestContentType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var input = "{SampleString:\""+requestContentType+"\"}"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_AmbiguousActions/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, requestContentType); + // Act + var response = await client.SendAsync(request); + var product = JsonConvert.DeserializeObject( + await response.Content.ReadAsStringAsync()); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(requestContentType, product.SampleString); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task ActionLevelAttribute_OveridesClassLevel(string requestContentType) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var input = "{SampleString:\"" + requestContentType + "\"}"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_OverridesBase/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, requestContentType); + var expectedString = "ConsumesAttribute_OverridesBaseController_" + requestContentType; + + // Act + var response = await client.SendAsync(request); + var product = JsonConvert.DeserializeObject( + await response.Content.ReadAsStringAsync()); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedString, product.SampleString); + } + + [Fact] + public async Task DerivedClassLevelAttribute_OveridesBaseClassLevel() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + var input = "" + + "application/xml"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_Overrides/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/xml"); + var expectedString = "ConsumesAttribute_OverridesController_application/xml"; + + // Act + var response = await client.SendAsync(request); + var responseString = await response.Content.ReadAsStringAsync(); + var product = JsonConvert.DeserializeObject(responseString); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedString, product.SampleString); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json b/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json index 502abe42d6..e29d0af3b5 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json @@ -3,6 +3,7 @@ "warningsAsErrors": "true" }, "dependencies": { + "ActionConstraintsWebSite": "1.0.0", "ActionResultsWebSite": "1.0.0", "ActivatorWebSite": "1.0.0", "AddServicesWebSite": "1.0.0", diff --git a/test/WebSites/ActionConstraintsWebSite/ActionConstraintsWebSite.kproj b/test/WebSites/ActionConstraintsWebSite/ActionConstraintsWebSite.kproj new file mode 100644 index 0000000000..f22c0494ee --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/ActionConstraintsWebSite.kproj @@ -0,0 +1,20 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + af210f69-9d31-43af-ac3a-cd366e252218 + + + + + + + 2.0 + 41642 + + + \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_NoFallBackActionController.cs b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_NoFallBackActionController.cs new file mode 100644 index 0000000000..c3fb5e1b7a --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_NoFallBackActionController.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ActionConstraintsWebSite +{ + [Route("ConsumesAttribute_AmbiguousActions/[action]")] + public class ConsumesAttribute_NoFallBackActionController : Controller + { + [Consumes("application/json", "text/json")] + public Product CreateProduct([FromBody] Product_Json jsonInput) + { + return jsonInput; + } + + [Consumes("application/xml")] + public Product CreateProduct([FromBody] Product_Xml xmlInput) + { + return xmlInput; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_OveridesBaseController.cs b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_OveridesBaseController.cs new file mode 100644 index 0000000000..3bd66da5fc --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_OveridesBaseController.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ActionConstraintsWebSite +{ + [Consumes("application/json")] + public class ConsumesAttribute_OverridesBaseController : Controller + { + [Consumes("text/json")] + public Product CreateProduct([FromBody] Product_Json product) + { + // should be picked if request content type is application/xml and not application/json. + product.SampleString = "ConsumesAttribute_OverridesBaseController_text/json"; + return product; + } + + public virtual IActionResult CreateProduct([FromBody] Product product) + { + // should be picked if request content type is application/json. + product.SampleString = "ConsumesAttribute_OverridesBaseController_application/json"; + return new ObjectResult(product); + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_OveridesController.cs b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_OveridesController.cs new file mode 100644 index 0000000000..68bf431727 --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_OveridesController.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace ActionConstraintsWebSite +{ + [Consumes("application/xml")] + public class ConsumesAttribute_OverridesController : ConsumesAttribute_OverridesBaseController + { + public override IActionResult CreateProduct([FromBody] Product product) + { + // should be picked if request content type is text/json. + product.SampleString = "ConsumesAttribute_OverridesController_application/xml"; + return new JsonResult(product); + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_PassThroughController.cs b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_PassThroughController.cs new file mode 100644 index 0000000000..c800adc50c --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_PassThroughController.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Mvc; + +namespace ActionConstraintsWebSite +{ + [Route("ConsumesAttribute_PassThrough/[action]")] + public class ConsumesAttribute_PassThroughController : Controller + { + [Consumes("application/json")] + public Product CreateProduct([FromBody] Product_Json jsonInput) + { + return jsonInput; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_WithFallbackActionController.cs b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_WithFallbackActionController.cs new file mode 100644 index 0000000000..454b597643 --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Controllers/ConsumesAttribute_WithFallbackActionController.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Mvc; + +namespace ActionConstraintsWebSite +{ + [Route("ConsumesAttribute_Company/[action]")] + public class ConsumesAttribute_WithFallbackActionController : Controller + { + [Consumes("application/json")] + public Product CreateProduct([FromBody] Product_Json jsonInput) + { + return jsonInput; + } + + [Consumes("application/xml")] + public Product CreateProduct([FromBody] Product_Xml xmlInput) + { + return xmlInput; + } + + public Product CreateProduct([FromBody] Product_Text defaultInput) + { + return defaultInput; + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Models/Product.cs b/test/WebSites/ActionConstraintsWebSite/Models/Product.cs new file mode 100644 index 0000000000..fa66f18ec7 --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Models/Product.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; + +namespace ActionConstraintsWebSite +{ + public class Product + { + [Range(10, 100)] + public int SampleInt { get; set; } + + [MinLength(15)] + public string SampleString { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Models/Product_Json.cs b/test/WebSites/ActionConstraintsWebSite/Models/Product_Json.cs new file mode 100644 index 0000000000..08c45c8cf2 --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Models/Product_Json.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace ActionConstraintsWebSite +{ + public class Product_Json : Product + { + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Models/Product_Xml.cs b/test/WebSites/ActionConstraintsWebSite/Models/Product_Xml.cs new file mode 100644 index 0000000000..88e6806888 --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Models/Product_Xml.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace ActionConstraintsWebSite +{ + public class Product_Xml : Product + { + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Models/Product_text.cs b/test/WebSites/ActionConstraintsWebSite/Models/Product_text.cs new file mode 100644 index 0000000000..33b4d0522b --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Models/Product_text.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace ActionConstraintsWebSite +{ + public class Product_Text : Product + { + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/Startup.cs b/test/WebSites/ActionConstraintsWebSite/Startup.cs new file mode 100644 index 0000000000..f01b2f2974 --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/Startup.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Routing; +using Microsoft.Framework.DependencyInjection; + +namespace ActionConstraintsWebSite +{ + public class Startup + { + public void Configure(IApplicationBuilder app) + { + var configuration = app.GetTestConfiguration(); + + app.UseServices(services => + { + services.AddMvc(configuration); + services.Configure(options => + { + options.AddXmlDataContractSerializerFormatter(); + }); + }); + + app.UseErrorReporter(); + + app.UseMvc(routes => + { + routes.MapRoute( + name: "default", + template: "{controller}/{action}/{id?}"); + }); + } + } +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/project.json b/test/WebSites/ActionConstraintsWebSite/project.json new file mode 100644 index 0000000000..464f3bbc61 --- /dev/null +++ b/test/WebSites/ActionConstraintsWebSite/project.json @@ -0,0 +1,19 @@ +{ + "commands": { + "web": "Microsoft.AspNet.Hosting server=Microsoft.AspNet.Server.WebListener server.urls=http://localhost:5001", + "kestrel": "Microsoft.AspNet.Hosting --server Kestrel --server.urls http://localhost:5000" + }, + "dependencies": { + "Kestrel": "1.0.0-*", + "Microsoft.AspNet.Mvc": "6.0.0-*", + "Microsoft.AspNet.Mvc.TestConfiguration": "1.0.0", + "Microsoft.AspNet.Server.IIS": "1.0.0-*", + "Microsoft.AspNet.Server.WebListener": "1.0.0-*", + "Microsoft.AspNet.StaticFiles": "1.0.0-*" + }, + "frameworks": { + "aspnet50": { }, + "aspnetcore50": { } + }, + "webroot": "wwwroot" +} \ No newline at end of file diff --git a/test/WebSites/ActionConstraintsWebSite/wwwroot/readme.md b/test/WebSites/ActionConstraintsWebSite/wwwroot/readme.md new file mode 100644 index 0000000000..1bd9fa1ec3 Binary files /dev/null and b/test/WebSites/ActionConstraintsWebSite/wwwroot/readme.md differ