From a67d9363e22be8ef63a1a62539991e1da3a6e30e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 12 Jul 2018 16:35:33 +1200 Subject: [PATCH] Consumes endpoint constraint (#8057) --- .../ConsumesAttribute.cs | 97 +++++++- .../Internal/IConsumesActionConstraint.cs | 9 + .../ConsumesAttributeTests.cs | 228 ++++++++++++++++-- .../ConsumesAttributeDispatchingTests.cs | 13 + .../ConsumesAttributeTests.cs | 160 +----------- .../ConsumesAttributeTestsBase.cs | 175 ++++++++++++++ .../BasicWebSite/StartupWithDispatching.cs | 37 +++ 7 files changed, 546 insertions(+), 173 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeDispatchingTests.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTestsBase.cs create mode 100644 test/WebSites/BasicWebSite/StartupWithDispatching.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs index c3c240e63e..7d63adcabf 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ConsumesAttribute.cs @@ -11,6 +11,8 @@ using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc @@ -24,7 +26,8 @@ namespace Microsoft.AspNetCore.Mvc Attribute, IResourceFilter, IConsumesActionConstraint, - IApiRequestMetadataProvider + IApiRequestMetadataProvider, + IConsumesEndpointConstraint { public static readonly int ConsumesActionConstraintOrder = 200; @@ -55,6 +58,11 @@ namespace Microsoft.AspNetCore.Mvc /// int IActionConstraint.Order => ConsumesActionConstraintOrder; + // The value used is a non default value so that it avoids getting mixed with other endpoint constraints + // with default order. + /// + int IEndpointConstraint.Order => ConsumesActionConstraintOrder; + /// /// Gets or sets the supported request content types. Used to select an action when there would otherwise be /// multiple matches. @@ -184,6 +192,83 @@ namespace Microsoft.AspNetCore.Mvc return true; } + /// + public bool Accept(EndpointConstraintContext context) + { + // If this constraint is not closest to the endpoint, it will be skipped. + if (!IsApplicable(context.CurrentCandidate.Endpoint)) + { + // 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 endpoint. + return true; + } + + var requestContentType = context.HttpContext.Request.ContentType; + + // 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 endpoints with consumes endpoint constraints this should result in ambiguous exception + // unless there is another endpoint without a consumes constraint. + if (requestContentType == null) + { + var isEndpointWithoutConsumeConstraintPresent = context.Candidates.Any( + candidate => candidate.Constraints == null || + !candidate.Constraints.Any(constraint => constraint is IConsumesEndpointConstraint)); + + return !isEndpointWithoutConsumeConstraintPresent; + } + + // Confirm the request's content type is more specific than (a media type this endpoint supports e.g. OK + // if client sent "text/plain" data and this endpoint supports "text/*". + if (IsSubsetOfAnyContentType(requestContentType)) + { + return true; + } + + var firstCandidate = context.Candidates[0]; + if (firstCandidate.Endpoint != context.CurrentCandidate.Endpoint) + { + // 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 IConsumesEndpointConstraints 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.Endpoint == firstCandidate.Endpoint) + { + continue; + } + + var tempContext = new EndpointConstraintContext() + { + Candidates = context.Candidates, + HttpContext = context.HttpContext, + CurrentCandidate = candidate + }; + + if (candidate.Constraints == null || candidate.Constraints.Count == 0 || + candidate.Constraints.Any(constraint => constraint is IConsumesEndpointConstraint && + 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 @@ -193,7 +278,17 @@ namespace Microsoft.AspNetCore.Mvc // 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 bool IsApplicable(Endpoint endpoint) + { + // 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 endpoint.Metadata.Last( + metadata => metadata is IConsumesEndpointConstraint) == this; } private MediaTypeCollection GetContentTypes(string firstArg, string[] args) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/IConsumesActionConstraint.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/IConsumesActionConstraint.cs index b004cbdcf3..2806bd496d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/IConsumesActionConstraint.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/IConsumesActionConstraint.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Routing.EndpointConstraints; namespace Microsoft.AspNetCore.Mvc.Internal { @@ -12,4 +13,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal public interface IConsumesActionConstraint : IActionConstraint { } + + /// + /// An constraint that identifies a type which can be used to select an action + /// based on incoming request. + /// + public interface IConsumesEndpointConstraint : IEndpointConstraint + { + } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs index a2738ed9a2..83bae7b4b0 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ConsumesAttributeTests.cs @@ -12,6 +12,8 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.EndpointConstraints; +using Microsoft.AspNetCore.Routing.Matchers; using Microsoft.Net.Http.Headers; using Moq; using Xunit; @@ -80,7 +82,7 @@ namespace Microsoft.AspNetCore.Mvc [InlineData("application/json")] [InlineData("application/json;Parameter1=12")] [InlineData("text/xml")] - public void Accept_MatchesForMachingRequestContentType(string contentType) + public void ActionConstraint_Accept_MatchesForMachingRequestContentType(string contentType) { // Arrange var constraint = new ConsumesAttribute("application/json", "text/xml"); @@ -104,7 +106,7 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] - public void Accept_TheFirstCandidateReturnsFalse_IfALaterOneMatches() + public void ActionConstraint_Accept_TheFirstCandidateReturnsFalse_IfALaterOneMatches() { // Arrange var constraint1 = new ConsumesAttribute("application/json", "text/xml"); @@ -114,7 +116,7 @@ namespace Microsoft.AspNetCore.Mvc new List() { new FilterDescriptor(constraint1, FilterScope.Action) } }; - var constraint2 = new Mock(); + var constraint2 = new Mock(); var action2 = new ActionDescriptor() { FilterDescriptors = @@ -142,7 +144,7 @@ namespace Microsoft.AspNetCore.Mvc [InlineData("application/custom")] [InlineData("")] [InlineData(null)] - public void Accept_ForNoMatchingCandidates_SelectsTheFirstCandidate(string contentType) + public void ActionConstraint_Accept_ForNoMatchingCandidates_SelectsTheFirstCandidate(string contentType) { // Arrange var constraint1 = new ConsumesAttribute("application/json", "text/xml"); @@ -152,7 +154,7 @@ namespace Microsoft.AspNetCore.Mvc new List() { new FilterDescriptor(constraint1, FilterScope.Action) } }; - var constraint2 = new Mock(); + var constraint2 = new Mock(); var action2 = new ActionDescriptor() { FilterDescriptors = @@ -179,7 +181,7 @@ namespace Microsoft.AspNetCore.Mvc [Theory] [InlineData("")] [InlineData(null)] - public void Accept_ForNoRequestType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + public void ActionConstraint_Accept_ForNoRequestType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) { // Arrange var constraint1 = new ConsumesAttribute("application/json"); @@ -219,7 +221,7 @@ namespace Microsoft.AspNetCore.Mvc [InlineData("application/xml")] [InlineData("application/custom")] [InlineData("invalid/invalid")] - public void Accept_UnrecognizedMediaType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + public void ActionConstraint_Accept_UnrecognizedMediaType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) { // Arrange var actionWithoutConstraint = new ActionDescriptor(); @@ -258,7 +260,7 @@ namespace Microsoft.AspNetCore.Mvc [Theory] [InlineData("")] [InlineData(null)] - public void Accept_ForNoRequestType_ReturnsTrueForAllConstraints(string contentType) + public void ActionConstraint_Accept_ForNoRequestType_ReturnsTrueForAllConstraints(string contentType) { // Arrange var constraint1 = new ConsumesAttribute("application/json"); @@ -293,6 +295,193 @@ namespace Microsoft.AspNetCore.Mvc Assert.True(constraint2.Accept(context)); } + private MatcherEndpoint CreateEndpoint(params IEndpointConstraint[] constraints) + { + EndpointMetadataCollection endpointMetadata = new EndpointMetadataCollection(constraints); + + return new MatcherEndpoint( + (r) => null, + "", + new RouteValueDictionary(), + new RouteValueDictionary(), + 0, + endpointMetadata, + ""); + } + + [Theory] + [InlineData("application/json")] + [InlineData("application/json;Parameter1=12")] + [InlineData("text/xml")] + public void EndpointConstraint_Accept_MatchesForMachingRequestContentType(string contentType) + { + // Arrange + var constraint = new ConsumesAttribute("application/json", "text/xml"); + var endpoint = CreateEndpoint(constraint); + + var context = new EndpointConstraintContext(); + context.Candidates = new List() + { + new EndpointSelectorCandidate(endpoint, new [] { constraint }), + }; + + context.CurrentCandidate = context.Candidates[0]; + context.HttpContext = CreateHttpContext(contentType: contentType); + + // Act & Assert + Assert.True(constraint.Accept(context)); + } + + [Fact] + public void EndpointConstraint_Accept_TheFirstCandidateReturnsFalse_IfALaterOneMatches() + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json", "text/xml"); + var endpoint1 = CreateEndpoint(constraint1); + + var constraint2 = new Mock(); + var endpoint2 = CreateEndpoint(constraint2.Object); + + constraint2.Setup(o => o.Accept(It.IsAny())) + .Returns(true); + + var context = new EndpointConstraintContext(); + context.Candidates = new List() + { + new EndpointSelectorCandidate(endpoint1, new [] { constraint1 }), + new EndpointSelectorCandidate(endpoint2, new [] { constraint2.Object }), + }; + + context.CurrentCandidate = context.Candidates[0]; + context.HttpContext = CreateHttpContext(contentType: "application/custom"); + + // Act & Assert + Assert.False(constraint1.Accept(context)); + } + + [Theory] + [InlineData("application/custom")] + [InlineData("")] + [InlineData(null)] + public void EndpointConstraint_Accept_ForNoMatchingCandidates_SelectsTheFirstCandidate(string contentType) + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json", "text/xml"); + var endpoint1 = CreateEndpoint(constraint1); + + var constraint2 = new Mock(); + var endpoint2 = CreateEndpoint(constraint2.Object); + + constraint2.Setup(o => o.Accept(It.IsAny())) + .Returns(false); + + var context = new EndpointConstraintContext(); + context.Candidates = new List() + { + new EndpointSelectorCandidate(endpoint1, new [] { constraint1 }), + new EndpointSelectorCandidate(endpoint2, new [] { constraint2.Object }), + }; + + context.CurrentCandidate = context.Candidates[0]; + context.HttpContext = CreateHttpContext(contentType: contentType); + + // Act & Assert + Assert.True(constraint1.Accept(context)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void EndpointConstraint_Accept_ForNoRequestType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json"); + var endpointWithConstraint = CreateEndpoint(constraint1); + + var constraint2 = new ConsumesAttribute("text/xml"); + var endpointWithConstraint2 = CreateEndpoint(constraint2); + + var endpointWithoutConstraint = CreateEndpoint(); + + var context = new EndpointConstraintContext(); + context.Candidates = new List() + { + new EndpointSelectorCandidate(endpointWithConstraint, new [] { constraint1 }), + new EndpointSelectorCandidate(endpointWithConstraint2, new [] { constraint2 }), + new EndpointSelectorCandidate(endpointWithoutConstraint, new List()), + }; + + context.HttpContext = CreateHttpContext(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 EndpointConstraint_Accept_UnrecognizedMediaType_SelectsTheCandidateWithoutConstraintIfPresent(string contentType) + { + // Arrange + var endpointWithoutConstraint = CreateEndpoint(); + var constraint1 = new ConsumesAttribute("application/json"); + var endpointWithConstraint = CreateEndpoint(constraint1); + + var constraint2 = new ConsumesAttribute("text/xml"); + var endpointWithConstraint2 = CreateEndpoint(constraint2); + + var context = new EndpointConstraintContext(); + context.Candidates = new List() + { + new EndpointSelectorCandidate(endpointWithConstraint, new [] { constraint1 }), + new EndpointSelectorCandidate(endpointWithConstraint2, new [] { constraint2 }), + new EndpointSelectorCandidate(endpointWithoutConstraint, new List()), + }; + + context.HttpContext = CreateHttpContext(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 EndpointConstraint_Accept_ForNoRequestType_ReturnsTrueForAllConstraints(string contentType) + { + // Arrange + var constraint1 = new ConsumesAttribute("application/json"); + var endpointWithConstraint = CreateEndpoint(constraint1); + + var constraint2 = new ConsumesAttribute("text/xml"); + var endpointWithConstraint2 = CreateEndpoint(constraint2); + + var endpointWithoutConstraint = CreateEndpoint(); + + var context = new EndpointConstraintContext(); + context.Candidates = new List() + { + new EndpointSelectorCandidate(endpointWithConstraint, new [] { constraint1 }), + new EndpointSelectorCandidate(endpointWithConstraint2, new [] { constraint2 }), + }; + + context.HttpContext = CreateHttpContext(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")] @@ -404,11 +593,7 @@ namespace Microsoft.AspNetCore.Mvc private static RouteContext CreateRouteContext(string contentType = null, object routeValues = null) { - var httpContext = new DefaultHttpContext(); - if (contentType != null) - { - httpContext.Request.ContentType = contentType; - } + var httpContext = CreateHttpContext(contentType); var routeContext = new RouteContext(httpContext); routeContext.RouteData = new RouteData(); @@ -421,7 +606,22 @@ namespace Microsoft.AspNetCore.Mvc return routeContext; } - public interface ITestConsumeConstraint : IConsumesActionConstraint, IResourceFilter + private static HttpContext CreateHttpContext(string contentType = null, object routeValues = null) + { + var httpContext = new DefaultHttpContext(); + if (contentType != null) + { + httpContext.Request.ContentType = contentType; + } + + return httpContext; + } + + public interface ITestActionConsumeConstraint : IConsumesActionConstraint, IResourceFilter + { + } + + public interface ITestEndpointConsumeConstraint : IConsumesEndpointConstraint, IResourceFilter { } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeDispatchingTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeDispatchingTests.cs new file mode 100644 index 0000000000..ced707f674 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeDispatchingTests.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class ConsumesAttributeDispatchingTests : ConsumesAttributeTestsBase + { + public ConsumesAttributeDispatchingTests(MvcTestFixture fixture) + : base(fixture) + { + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs index 181094b715..a8db21d806 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTests.cs @@ -1,169 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using BasicWebSite.Models; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Testing.xunit; -using Newtonsoft.Json; -using Xunit; - namespace Microsoft.AspNetCore.Mvc.FunctionalTests { - public class ConsumesAttributeTests : IClassFixture> + public class ConsumesAttributeTests : ConsumesAttributeTestsBase { public ConsumesAttributeTests(MvcTestFixture fixture) + : base(fixture) { - Client = fixture.CreateDefaultClient(); - } - - public HttpClient Client { get; } - - [Fact] - public async Task NoRequestContentType_SelectsActionWithoutConstraint() - { - // Arrange - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_Company/CreateProduct"); - - // Act - var response = await Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("CreateProduct_Product_Text", body); - } - - [Fact] - public async Task NoRequestContentType_Selects_IfASingleActionWithConstraintIsPresent() - { - // Arrange - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_PassThrough/CreateProduct"); - - // Act - var response = await Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("ConsumesAttribute_PassThrough_Product_Json", body); - } - - [Theory] - [InlineData("application/json")] - [InlineData("text/json")] - public async Task Selects_Action_BasedOnRequestContentType(string requestContentType) - { - // Arrange - 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 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); - } - - [ConditionalFact] - // Mono issue - https://github.com/aspnet/External/issues/18 - [FrameworkSkipCondition(RuntimeFrameworks.Mono)] - public async Task DerivedClassLevelAttribute_OveridesBaseClassLevel() - { - // Arrange - 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); - } - - [Fact] - public async Task JsonSyntaxSuffix_SelectsActionConsumingJson() - { - // Arrange - var input = "{SampleString:\"some input\"}"; - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); - request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+json"); - - // Act - var response = await Client.SendAsync(request); - var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("Read from JSON: some input", product.SampleString); - } - - [ConditionalFact] - // Mono issue - https://github.com/aspnet/External/issues/18 - [FrameworkSkipCondition(RuntimeFrameworks.Mono)] - public async Task XmlSyntaxSuffix_SelectsActionConsumingXml() - { - // Arrange - var input = "" + - "some input"; - var request = new HttpRequestMessage( - HttpMethod.Post, - "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); - request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+xml"); - - // Act - var response = await Client.SendAsync(request); - var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("Read from XML: some input", product.SampleString); } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTestsBase.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTestsBase.cs new file mode 100644 index 0000000000..9a414488b1 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ConsumesAttributeTestsBase.cs @@ -0,0 +1,175 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using BasicWebSite.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Testing.xunit; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public abstract class ConsumesAttributeTestsBase : IClassFixture> where TStartup : class + { + protected ConsumesAttributeTestsBase(MvcTestFixture fixture) + { + var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder); + Client = factory.CreateDefaultClient(); + } + + private static void ConfigureWebHostBuilder(IWebHostBuilder builder) => + builder.UseStartup(); + + public HttpClient Client { get; } + + [Fact] + public async Task NoRequestContentType_SelectsActionWithoutConstraint() + { + // Arrange + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_Company/CreateProduct"); + + // Act + var response = await Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("CreateProduct_Product_Text", body); + } + + [Fact] + public async Task NoRequestContentType_Selects_IfASingleActionWithConstraintIsPresent() + { + // Arrange + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_PassThrough/CreateProduct"); + + // Act + var response = await Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("ConsumesAttribute_PassThrough_Product_Json", body); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/json")] + public async Task Selects_Action_BasedOnRequestContentType(string requestContentType) + { + // Arrange + 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 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); + } + + [ConditionalFact] + // Mono issue - https://github.com/aspnet/External/issues/18 + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + public async Task DerivedClassLevelAttribute_OveridesBaseClassLevel() + { + // Arrange + 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); + } + + [Fact] + public async Task JsonSyntaxSuffix_SelectsActionConsumingJson() + { + // Arrange + var input = "{SampleString:\"some input\"}"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+json"); + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Read from JSON: some input", product.SampleString); + } + + [ConditionalFact] + // Mono issue - https://github.com/aspnet/External/issues/18 + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + public async Task XmlSyntaxSuffix_SelectsActionConsumingXml() + { + // Arrange + var input = "" + + "some input"; + var request = new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/ConsumesAttribute_MediaTypeSuffix/CreateProduct"); + request.Content = new StringContent(input, Encoding.UTF8, "application/vnd.example+xml"); + + // Act + var response = await Client.SendAsync(request); + var product = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Read from XML: some input", product.SampleString); + } + } +} \ No newline at end of file diff --git a/test/WebSites/BasicWebSite/StartupWithDispatching.cs b/test/WebSites/BasicWebSite/StartupWithDispatching.cs new file mode 100644 index 0000000000..4c5ed0dc1e --- /dev/null +++ b/test/WebSites/BasicWebSite/StartupWithDispatching.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace BasicWebSite +{ + public class StartupWithDispatching + { + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + services.AddDispatcher(); + + services.AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest) + .AddXmlDataContractSerializerFormatters(); + + services.ConfigureBaseWebSiteAuthPolicies(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseDispatcher(); + + app.UseMvcWithEndpoint(routes => + { + routes.MapEndpoint( + "ActionAsMethod", + "{controller}/{action}", + defaults: new { controller = "Home", action = "Index" }); + }); + } + } +} \ No newline at end of file