diff --git a/Mvc.sln b/Mvc.sln index 1eb7f54602..830bd9f81e 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -172,6 +172,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorPagesClassLibrary", "t EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Views.TestCommon", "test\Microsoft.AspNetCore.Mvc.Views.TestCommon\Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj", "{51E3E785-A9D1-4196-BAFE-A17FF4304B89}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DispatchingWebSite", "test\WebSites\DispatchingWebSite\DispatchingWebSite.csproj", "{ABB3737F-E518-4E40-8A9C-F3281D610E8F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -906,6 +908,18 @@ Global {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|Mixed Platforms.Build.0 = Release|Any CPU {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|x86.ActiveCfg = Release|Any CPU {51E3E785-A9D1-4196-BAFE-A17FF4304B89}.Release|x86.Build.0 = Release|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Debug|x86.ActiveCfg = Debug|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Debug|x86.Build.0 = Debug|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Release|Any CPU.Build.0 = Release|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Release|x86.ActiveCfg = Release|Any CPU + {ABB3737F-E518-4E40-8A9C-F3281D610E8F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -976,6 +990,7 @@ Global {E83D3745-9BCF-40E8-8D34-AFBA604C2439} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} {17122147-ADFD-41C8-87D9-CCC582CCA8F9} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {51E3E785-A9D1-4196-BAFE-A17FF4304B89} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} + {ABB3737F-E518-4E40-8A9C-F3281D610E8F} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {63D344F6-F86D-40E6-85B9-0AABBE338C4A} diff --git a/build/dependencies.props b/build/dependencies.props index d3cfd72116..3f7994e63f 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -5,91 +5,91 @@ 0.9.9 0.10.13 - 2.2.0-preview1-34425 + 2.2.0-preview1-34462 2.2.0-preview1-17082 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 5.2.6 2.8.0 2.8.0 - 2.2.0-preview1-34425 + 2.2.0-preview1-34462 1.7.0 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-26606-01 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-26612-04 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 2.0.0 2.1.0 - 2.2.0-preview1-26606-01 - 2.2.0-preview1-34425 - 2.2.0-preview1-34425 + 2.2.0-preview1-26612-04 + 2.2.0-preview1-34462 + 2.2.0-preview1-34462 15.6.1 4.7.49 2.0.3 1.0.1 - 4.6.0-preview1-26605-01 - 4.6.0-preview1-26605-01 - 4.6.0-preview1-26605-01 + 4.6.0-preview1-26611-02 + 4.6.0-preview1-26611-02 + 4.6.0-preview1-26611-02 0.8.0 2.3.1 2.4.0-beta.1.build3945 diff --git a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index c0bfe2039e..84b77646bc 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -260,6 +260,13 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton(); // Only one per app services.TryAddTransient(); // Many per app + // + // Dispatching + // + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + // // Middleware pipeline filter related // diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs new file mode 100644 index 0000000000..131e545e67 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointDataSource.cs @@ -0,0 +1,139 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.EndpointConstraints; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal class MvcEndpointDataSource : EndpointDataSource + { + private readonly IActionDescriptorCollectionProvider _actions; + private readonly MvcEndpointInvokerFactory _invokerFactory; + private readonly IActionDescriptorChangeProvider[] _actionDescriptorChangeProviders; + private readonly List _endpoints; + + private IChangeToken _changeToken; + + public MvcEndpointDataSource( + IActionDescriptorCollectionProvider actions, + MvcEndpointInvokerFactory invokerFactory, + IEnumerable actionDescriptorChangeProviders) + { + if (actions == null) + { + throw new ArgumentNullException(nameof(actions)); + } + + if (invokerFactory == null) + { + throw new ArgumentNullException(nameof(invokerFactory)); + } + + if (actionDescriptorChangeProviders == null) + { + throw new ArgumentNullException(nameof(actionDescriptorChangeProviders)); + } + + _actions = actions; + _invokerFactory = invokerFactory; + _actionDescriptorChangeProviders = actionDescriptorChangeProviders.ToArray(); + + _endpoints = new List(); + + InitializeEndpoints(); + } + + private void InitializeEndpoints() + { + // note: this code has haxxx. This will only work in some constrained scenarios + foreach (var action in _actions.ActionDescriptors.Items) + { + if (action.AttributeRouteInfo == null) + { + // Action does not have an attribute route + continue; + } + + RequestDelegate invokerDelegate = (context) => + { + var values = context.Features.Get().Values; + var routeData = new RouteData(); + foreach (var kvp in values) + { + routeData.Values.Add(kvp.Key, kvp.Value); + } + + var actionContext = new ActionContext(context, routeData, action); + + var invoker = _invokerFactory.CreateInvoker(actionContext); + return invoker.InvokeAsync(); + }; + + var metadata = new List(); + + // Add filter descriptors to endpoint metadata + metadata.AddRange(action.FilterDescriptors.OrderBy(f => f, FilterDescriptorOrderComparer.Comparer).Select(f => f.Filter)); + + if (action.ActionConstraints != null && action.ActionConstraints.Count > 0) + { + foreach (var actionConstraint in action.ActionConstraints) + { + if (actionConstraint is HttpMethodActionConstraint httpMethodActionConstraint) + { + metadata.Add(new HttpMethodEndpointConstraint(httpMethodActionConstraint.HttpMethods)); + } + } + } + + var metadataCollection = new EndpointMetadataCollection(metadata); + + _endpoints.Add(new MatcherEndpoint( + next => invokerDelegate, + action.AttributeRouteInfo.Template, + action.RouteValues, + action.AttributeRouteInfo.Order, + metadataCollection, + action.DisplayName)); + } + } + + private IChangeToken GetCompositeChangeToken() + { + if (_actionDescriptorChangeProviders.Length == 1) + { + return _actionDescriptorChangeProviders[0].GetChangeToken(); + } + + var changeTokens = new IChangeToken[_actionDescriptorChangeProviders.Length]; + for (var i = 0; i < _actionDescriptorChangeProviders.Length; i++) + { + changeTokens[i] = _actionDescriptorChangeProviders[i].GetChangeToken(); + } + + return new CompositeChangeToken(changeTokens); + } + + public override IChangeToken ChangeToken + { + get + { + if (_changeToken == null) + { + _changeToken = GetCompositeChangeToken(); + } + + return _changeToken; + } + } + + public override IReadOnlyList Endpoints => _endpoints; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointInvokerFactory.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointInvokerFactory.cs new file mode 100644 index 0000000000..3737a3b718 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcEndpointInvokerFactory.cs @@ -0,0 +1,41 @@ +// 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.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace Microsoft.AspNetCore.Mvc.Internal +{ + internal sealed class MvcEndpointInvokerFactory : IActionInvokerFactory + { + private readonly IActionInvokerFactory _invokerFactory; + private readonly IActionContextAccessor _actionContextAccessor; + + public MvcEndpointInvokerFactory( + IActionInvokerFactory invokerFactory) + : this(invokerFactory, actionContextAccessor: null) + { + } + + public MvcEndpointInvokerFactory( + IActionInvokerFactory invokerFactory, + IActionContextAccessor actionContextAccessor) + { + _invokerFactory = invokerFactory; + + // The IActionContextAccessor is optional. We want to avoid the overhead of using CallContext + // if possible. + _actionContextAccessor = actionContextAccessor; + } + + public IActionInvoker CreateInvoker(ActionContext actionContext) + { + if (_actionContextAccessor != null) + { + _actionContextAccessor.ActionContext = actionContext; + } + + return _invokerFactory.CreateInvoker(actionContext); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs index 237eb47046..e2e7412818 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs @@ -306,6 +306,13 @@ namespace Microsoft.AspNetCore.Mvc typeof(ApiBehaviorApplicationModelProvider), } }, + { + typeof(EndpointDataSource), + new Type[] + { + typeof(MvcEndpointDataSource), + } + }, }; } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs new file mode 100644 index 0000000000..75eac34e56 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/MvcEndpointDataSourceTests.cs @@ -0,0 +1,170 @@ +// 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; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.Primitives; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Core.Test.Internal +{ + public class MvcEndpointDataSourceTests + { + [Fact] + public void Endpoints_AccessParameters_InitializedFromProvider() + { + // Arrange + var routeValue = "Value"; + var routeValues = new Dictionary + { + ["Name"] = routeValue + }; + var displayName = "DisplayName!"; + var order = 1; + var template = "/Template!"; + var filterDescriptor = new FilterDescriptor(new ControllerActionFilter(), 1); + + var mockDescriptorProvider = new Mock(); + mockDescriptorProvider.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + new ActionDescriptor + { + RouteValues = routeValues, + DisplayName = displayName, + AttributeRouteInfo = new AttributeRouteInfo + { + Order = order, + Template = template + }, + FilterDescriptors = new List + { + filterDescriptor + } + } + }, 0)); + + // Act + var dataSource = new MvcEndpointDataSource( + mockDescriptorProvider.Object, + new MvcEndpointInvokerFactory(new ActionInvokerFactory(Array.Empty())), + Array.Empty()); + + // Assert + var endpoint = Assert.Single(dataSource.Endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + + object endpointValue = matcherEndpoint.Values["Name"]; + Assert.Equal(routeValue, endpointValue); + + Assert.Equal(displayName, matcherEndpoint.DisplayName); + Assert.Equal(order, matcherEndpoint.Order); + Assert.Equal(template, matcherEndpoint.Template); + } + + [Fact] + public void Endpoints_InvokeReturnedEndpoint_ActionInvokerProviderCalled() + { + // Arrange + var featureCollection = new FeatureCollection(); + featureCollection.Set(new EndpointFeature + { + Values = new RouteValueDictionary() + }); + + var httpContextMock = new Mock(); + httpContextMock.Setup(m => m.Features).Returns(featureCollection); + + var mockDescriptorProviderMock = new Mock(); + mockDescriptorProviderMock.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List + { + new ActionDescriptor + { + AttributeRouteInfo = new AttributeRouteInfo + { + Template = string.Empty + }, + FilterDescriptors = new List() + } + }, 0)); + + var actionInvokerCalled = false; + var actionInvokerMock = new Mock(); + actionInvokerMock.Setup(m => m.InvokeAsync()).Returns(() => + { + actionInvokerCalled = true; + return Task.CompletedTask; + }); + + var actionInvokerProviderMock = new Mock(); + actionInvokerProviderMock.Setup(m => m.CreateInvoker(It.IsAny())).Returns(actionInvokerMock.Object); + + // Act + var dataSource = new MvcEndpointDataSource( + mockDescriptorProviderMock.Object, + new MvcEndpointInvokerFactory(actionInvokerProviderMock.Object), + Array.Empty()); + + // Assert + var endpoint = Assert.Single(dataSource.Endpoints); + var matcherEndpoint = Assert.IsType(endpoint); + + var invokerDelegate = matcherEndpoint.Invoker((next) => Task.CompletedTask); + + invokerDelegate(httpContextMock.Object); + + Assert.True(actionInvokerCalled); + } + + [Fact] + public void ChangeToken_MultipleChangeTokenProviders_ComposedResult() + { + // Arrange + var featureCollection = new FeatureCollection(); + featureCollection.Set(new EndpointFeature + { + Values = new RouteValueDictionary() + }); + + var httpContextMock = new Mock(); + httpContextMock.Setup(m => m.Features).Returns(featureCollection); + + var mockDescriptorProviderMock = new Mock(); + mockDescriptorProviderMock.Setup(m => m.ActionDescriptors).Returns(new ActionDescriptorCollection(new List(), 0)); + + var actionInvokerMock = new Mock(); + + var actionInvokerProviderMock = new Mock(); + actionInvokerProviderMock.Setup(m => m.CreateInvoker(It.IsAny())).Returns(actionInvokerMock.Object); + + var changeTokenMock = new Mock(); + + var changeProvider1Mock = new Mock(); + changeProvider1Mock.Setup(m => m.GetChangeToken()).Returns(changeTokenMock.Object); + var changeProvider2Mock = new Mock(); + changeProvider2Mock.Setup(m => m.GetChangeToken()).Returns(changeTokenMock.Object); + + var dataSource = new MvcEndpointDataSource( + mockDescriptorProviderMock.Object, + new MvcEndpointInvokerFactory(actionInvokerProviderMock.Object), + new[] { changeProvider1Mock.Object, changeProvider2Mock.Object }); + + // Act + var changeToken = dataSource.ChangeToken; + + // Assert + var compositeChangeToken = Assert.IsType(changeToken); + Assert.Equal(2, compositeChangeToken.ChangeTokens.Count); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DispatchingTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DispatchingTests.cs new file mode 100644 index 0000000000..99d7472b60 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/DispatchingTests.cs @@ -0,0 +1,1259 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class DispatchingTests : IClassFixture> + { + public DispatchingTests(MvcTestFixture fixture) + { + Client = fixture.CreateDefaultClient(); + } + + public HttpClient Client { get; } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedController_ActionIsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/Index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Home/Index", result.ExpectedUrls); + Assert.Equal("Home", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" }, + }, + result.RouteValues); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedController_ActionIsReachable_WithDefaults() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/", result.ExpectedUrls); + Assert.Equal("Home", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" }, + }, + result.RouteValues); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedController_NonActionIsNotReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/NotAnAction"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedController_InArea_ActionIsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Travel/Flight/Index"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Travel/Flight/Index", result.ExpectedUrls); + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + Assert.Equal( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", "Travel" }, + { "controller", "Flight" }, + { "action", "Index" }, + }, + result.RouteValues); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedController_InArea_ActionBlockedByHttpMethod() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Travel/Flight/BuyTickets"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory(Skip = "Conventional routing WIP")] + [InlineData("", "/Home/OptionalPath/default")] + [InlineData("CustomPath", "/Home/OptionalPath/CustomPath")] + public async Task ConventionalRoutedController_WithOptionalSegment(string optionalSegment, string expected) + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/OptionalPath/" + optionalSegment); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Single(result.ExpectedUrls, expected); + } + + [Fact] + public async Task AttributeRoutedAction_IsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Store/Shop/Products"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Store/Shop/Products", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("ListProducts", result.Action); + + Assert.Contains( + new KeyValuePair("controller", "Store"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("action", "ListProducts"), + result.RouteValues); + } + + [Theory] + [InlineData("Get", "/Friends")] + [InlineData("Get", "/Friends/Peter")] + [InlineData("Delete", "/Friends")] + public async Task AttributeRoutedAction_AcceptRequestsWithValidMethods_InRoutesWithoutExtraTemplateSegmentsOnTheAction( + string method, + string url) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(method), $"http://localhost{url}"); + + // Assert + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains(url, result.ExpectedUrls); + Assert.Equal("Friends", result.Controller); + Assert.Equal(method, result.Action); + + Assert.Contains( + new KeyValuePair("controller", "Friends"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("action", method), + result.RouteValues); + + if (result.RouteValues.ContainsKey("id")) + { + Assert.Contains( + new KeyValuePair("id", "Peter"), + result.RouteValues); + } + } + + [Theory] + [InlineData("Post", "/Friends")] + [InlineData("Put", "/Friends")] + [InlineData("Patch", "/Friends")] + [InlineData("Options", "/Friends")] + [InlineData("Head", "/Friends")] + public async Task AttributeRoutedAction_RejectsRequestsWithWrongMethods_InRoutesWithoutExtraTemplateSegmentsOnTheAction( + string method, + string url) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(method), $"http://localhost{url}"); + + // Assert + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory(Skip = "URL generation WIP")] + [InlineData("http://localhost/api/v1/Maps")] + [InlineData("http://localhost/api/v2/Maps")] + public async Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithNameAndOrder(string url) + { + // Arrange & Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Maps", result.Controller); + Assert.Equal("Get", result.Action); + + Assert.Equal(new string[] + { + "/api/v2/Maps", + "/api/v1/Maps", + "/api/v2/Maps" + }, + result.ExpectedUrls); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_MultipleRouteAttributes_WorksWithOverrideRoutes() + { + // Arrange + var url = "http://localhost/api/v2/Maps"; + + // Act + var response = await Client.SendAsync(new HttpRequestMessage(HttpMethod.Post, url)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Maps", result.Controller); + Assert.Equal("Post", result.Action); + + Assert.Equal(new string[] + { + "/api/v2/Maps", + "/api/v2/Maps" + }, + result.ExpectedUrls); + } + + [Fact] + public async Task AttributeRoutedAction_MultipleRouteAttributes_RouteAttributeTemplatesIgnoredForOverrideActions() + { + // Arrange + var url = "http://localhost/api/v1/Maps"; + + // Act + var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod("POST"), url)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory(Skip = "URL generation WIP")] + [InlineData("http://localhost/api/v1/Maps/5", "PUT")] + [InlineData("http://localhost/api/v2/Maps/5", "PUT")] + [InlineData("http://localhost/api/v1/Maps/PartialUpdate/5", "PATCH")] + [InlineData("http://localhost/api/v2/Maps/PartialUpdate/5", "PATCH")] + public async Task AttributeRoutedAction_MultipleRouteAttributes_CombinesWithMultipleHttpAttributes( + string url, + string method) + { + // Arrange & Act + var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod(method), url)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Maps", result.Controller); + Assert.Equal("Update", result.Action); + + Assert.Equal(new string[] + { + "/api/v2/Maps/PartialUpdate/5", + "/api/v2/Maps/PartialUpdate/5" + }, + result.ExpectedUrls); + } + + [Theory(Skip = "URL generation WIP")] + [InlineData("http://localhost/Banks/Get/5")] + [InlineData("http://localhost/Bank/Get/5")] + public async Task AttributeRoutedAction_MultipleHttpAttributesAndTokenReplacement(string url) + { + // Arrange + var expectedUrl = new Uri(url).AbsolutePath; + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Banks", result.Controller); + Assert.Equal("Get", result.Action); + + Assert.Equal(new string[] + { + "/Bank/Get/5", + "/Bank/Get/5" + }, + result.ExpectedUrls); + } + + [Theory] + [InlineData("http://localhost/api/v1/Maps/5", "PATCH")] + [InlineData("http://localhost/api/v2/Maps/5", "PATCH")] + [InlineData("http://localhost/api/v1/Maps/PartialUpdate/5", "PUT")] + [InlineData("http://localhost/api/v2/Maps/PartialUpdate/5", "PUT")] + public async Task AttributeRoutedAction_MultipleRouteAttributes_WithMultipleHttpAttributes_RespectsConstraints( + string url, + string method) + { + // Arrange + var expectedUrl = new Uri(url).AbsolutePath; + + // Act + var response = await Client.SendAsync(new HttpRequestMessage(new HttpMethod(method), url)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // The url would be /Store/ListProducts with conventional routes + [Fact] + public async Task AttributeRoutedAction_IsNotReachableWithTraditionalRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Store/ListProducts"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // There's two actions at this URL - but attribute routes go in the route table + // first. + [Fact] + public async Task AttributeRoutedAction_TriedBeforeConventionalRouting() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Home/About"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Home/About", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("About", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_ControllerLevelRoute_WithActionParameter_IsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Blog/Edit/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Blog/Edit/5", result.ExpectedUrls); + Assert.Equal("Blog", result.Controller); + Assert.Equal("Edit", result.Action); + + Assert.Contains( + new KeyValuePair("controller", "Blog"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("action", "Edit"), + result.RouteValues); + + Assert.Contains( + new KeyValuePair("postId", "5"), + result.RouteValues); + } + + // There's no [HttpGet] on the action here. + [Fact] + public async Task AttributeRoutedAction_ControllerLevelRoute_IsReachable() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/api/Employee"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + } + + // We are intentionally skipping GET because we have another method with [HttpGet] on the same controller + // and a test that verifies that if you define another action with a specific verb we'll route to that + // more specific action. + [Theory] + [InlineData("PUT")] + [InlineData("POST")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public async Task AttributeRoutedAction_RouteAttributeOnAction_IsReachable(string method) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/Store/Shop/Orders"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Store/Shop/Orders", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("Orders", result.Action); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public async Task AttributeRoutedAction_RouteAttributeOnActionAndController_IsReachable(string method) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/api/Employee/5/Salary"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/5/Salary", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("Salary", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_RouteAttributeOnActionAndHttpGetOnDifferentAction_ReachesHttpGetAction() + { + // Arrange + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Store/Shop/Orders"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Store/Shop/Orders", result.ExpectedUrls); + Assert.Equal("Store", result.Controller); + Assert.Equal("GetOrders", result.Action); + } + + // There's no [HttpGet] on the action here. + [Theory] + [InlineData("PUT")] + [InlineData("PATCH")] + public async Task AttributeRoutedAction_ControllerLevelRoute_WithAcceptVerbs_IsReachable(string verb) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("UpdateEmployee", result.Action); + } + + [Theory] + [InlineData("PUT")] + [InlineData("PATCH")] + public async Task AttributeRoutedAction_ControllerLevelRoute_WithAcceptVerbsAndRouteTemplate_IsReachable(string verb) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee/Manager"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/Manager", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("UpdateManager", result.Action); + } + + [Theory(Skip = "URL generation WIP")] + [InlineData("PUT", "Bank")] + [InlineData("PATCH", "Bank")] + [InlineData("PUT", "Bank/Update")] + [InlineData("PATCH", "Bank/Update")] + public async Task AttributeRoutedAction_AcceptVerbsAndRouteTemplate_IsReachable(string verb, string path) + { + // Arrange + var expectedUrl = "/Bank/Update"; + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/" + path); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal(new string[] { expectedUrl, expectedUrl }, result.ExpectedUrls); + Assert.Equal("Banks", result.Controller); + Assert.Equal("UpdateBank", result.Action); + } + + [Fact] + public async Task AttributeRoutedAction_WithCustomHttpAttributes_IsReachable() + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod("MERGE"), "http://localhost/api/Employee/5"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/5", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("MergeEmployee", result.Action); + } + + // There's an [HttpGet] with its own template on the action here. + [Theory] + [InlineData("GET", "GetAdministrator")] + [InlineData("DELETE", "DeleteAdministrator")] + public async Task AttributeRoutedAction_ControllerLevelRoute_CombinedWithActionRoute_IsReachable(string verb, string action) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(verb), "http://localhost/api/Employee/5/Administrator"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/api/Employee/5/Administrator", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal(action, result.Action); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + + [Fact] + public async Task AttributeRoutedAction_ActionLevelRouteWithTildeSlash_OverridesControllerLevelRoute() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Manager/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Manager/5", result.ExpectedUrls); + Assert.Equal("Employee", result.Controller); + Assert.Equal("GetManager", result.Action); + + Assert.Contains( + new KeyValuePair("id", "5"), + result.RouteValues); + } + + [Fact] + public async Task AttributeRoutedAction_OverrideActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Team/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Team/5", result.ExpectedUrls); + Assert.Equal("Team", result.Controller); + Assert.Equal("GetOrganization", result.Action); + + Assert.Contains( + new KeyValuePair("teamId", "5"), + result.RouteValues); + } + + [Fact] + public async Task AttributeRoutedAction_OrderOnActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/Teams"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains("/Teams", result.ExpectedUrls); + Assert.Equal("Team", result.Controller); + Assert.Equal("GetOrganizations", result.Action); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkGeneration_OverrideActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetStringAsync("http://localhost/Organization/5"); + + // Assert + Assert.NotNull(response); + Assert.Equal("/Club/5", response); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkGeneration_OrderOnActionOverridesOrderOnController() + { + // Arrange & Act + var response = await Client.GetStringAsync("http://localhost/Teams/AllTeams"); + + // Assert + Assert.NotNull(response); + Assert.Equal("/Teams/AllOrganizations", response); + } + + [Theory] + [InlineData("", "/TeamName/DefaultName")] + [InlineData("CustomName", "/TeamName/CustomName")] + public async Task AttributeRoutedAction_PreservesDefaultValue_IfRouteValueIsNull(string teamName, string expected) + { + // Arrange & Act + var body = await Client.GetStringAsync("http://localhost/TeamName/" + teamName); + + // Assert + Assert.NotNull(body); + var result = JsonConvert.DeserializeObject(body); + Assert.Single(result.ExpectedUrls, expected); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkToSelf() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/api/Employee", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkWithAmbientController() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Get", id = 5 }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/api/Employee/5", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkToAttributeRoutedController() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "ShowPosts", controller = "Blog" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/Blog/ShowPosts", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkToConventionalController() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Index", controller = "Home" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/", result.Link); + } + + [Theory(Skip = "URL generation WIP")] + [InlineData("GET", "Get")] + [InlineData("PUT", "Put")] + public async Task AttributeRoutedAction_LinkWithName_WithNameInheritedFromControllerRoute( + string method, + string actionName) + { + // Arrange + var message = new HttpRequestMessage(new HttpMethod(method), "http://localhost/api/Company/5"); + + // Act + var response = await Client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal(actionName, result.Action); + + Assert.Equal("/api/Company/5", result.ExpectedUrls.Single()); + Assert.Equal("Company", result.RouteName); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkWithName_WithNameOverrridenFromController() + { + // Arrange & Act + var response = await Client.DeleteAsync("http://localhost/api/Company/5"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal("Delete", result.Action); + + Assert.Equal("/api/Company/5", result.ExpectedUrls.Single()); + Assert.Equal("RemoveCompany", result.RouteName); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_Link_WithNonEmptyActionRouteTemplateAndNoActionRouteName() + { + // Arrange + var url = LinkFrom("http://localhost") + .To(new { id = 5 }); + + // Act + var response = await Client.GetAsync("http://localhost/api/Company/5/Employees"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal("GetEmployees", result.Action); + + Assert.Equal("/api/Company/5/Employees", result.ExpectedUrls.Single()); + Assert.Null(result.RouteName); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkWithName_WithNonEmptyActionRouteTemplateAndActionRouteName() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/api/Company/5/Departments"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Company", result.Controller); + Assert.Equal("GetDepartments", result.Action); + + Assert.Equal("/api/Company/5/Departments", result.ExpectedUrls.Single()); + Assert.Equal("Departments", result.RouteName); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedAction_LinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/") + .To(new { action = "BuyTickets", controller = "Flight", area = "Travel" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Home", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight/BuyTickets", result.Link); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedAction_InArea_ImplicitLinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "BuyTickets" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight/BuyTickets", result.Link); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedAction_InArea_ExplicitLeaveArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "Index", controller = "Home", area = "" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/", result.Link); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedAction_InArea_StaysInArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "Contact", controller = "Home", }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Home/Contact", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_LinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/api/Employee") + .To(new { action = "Schedule", controller = "Rail", area = "Travel" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/ContosoCorp/Trains/CheckSchedule", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_InArea_ImplicitLinkToArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule").To(new { action = "Index" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Schedule", result.Action); + + Assert.Equal("/ContosoCorp/Trains", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_InArea_ExplicitLeaveArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule") + .To(new { action = "Index", controller = "Home", area = "" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Schedule", result.Action); + + Assert.Equal("/", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_InArea_StaysInArea_ActionDoesntExist() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "Contact", controller = "Home", }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Home/Contact", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_InArea_LinkToConventionalRoutedActionInArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "Index", controller = "Flight", }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight", result.Link); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedAction_InArea_LinkToAttributeRoutedActionInArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "Index", controller = "Rail", }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/ContosoCorp/Trains", result.Link); + } + + [Fact(Skip = "Conventional routing WIP")] + public async Task ConventionalRoutedAction_InArea_LinkToAnotherArea() + { + // Arrange + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Admin/Users/All", result.Link); + } + + [Fact(Skip = "URL generation WIP")] + public async Task AttributeRoutedAction_InArea_LinkToAnotherArea() + { + // Arrange + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); + + // Act + var response = await Client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Admin/Users/All", result.Link); + } + + [Theory] + [InlineData("/Bank/Deposit", "PUT", "Deposit")] + [InlineData("/Bank/Deposit", "POST", "Deposit")] + [InlineData("/Bank/Deposit/5", "PUT", "Deposit")] + [InlineData("/Bank/Deposit/5", "POST", "Deposit")] + [InlineData("/Bank/Withdraw/5", "POST", "Withdraw")] + public async Task AttributeRouting_MixedAcceptVerbsAndRoute_Reachable(string path, string verb, string actionName) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains(path, result.ExpectedUrls); + Assert.Equal("Banks", result.Controller); + Assert.Equal(actionName, result.Action); + } + + // These verbs don't match + [Theory] + [InlineData("/Bank/Deposit", "GET")] + [InlineData("/Bank/Deposit/5", "DELETE")] + [InlineData("/Bank/Withdraw/5", "GET")] + public async Task AttributeRouting_MixedAcceptVerbsAndRoute_Unreachable(string path, string verb) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("/Order/Add/1", "GET", "Add")] + [InlineData("/Order/Add", "POST", "Add")] + [InlineData("/Order/Edit/1", "PUT", "Edit")] + [InlineData("/Order/GetOrder", "GET", "GetOrder")] + public async Task AttributeRouting_RouteNameTokenReplace_Reachable(string path, string verb, string actionName) + { + // Arrange + var request = new HttpRequestMessage(new HttpMethod(verb), "http://localhost" + path); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + Assert.Contains(path, result.ExpectedUrls); + Assert.Equal("Order", result.Controller); + Assert.Equal(actionName, result.Action); + } + + private static LinkBuilder LinkFrom(string url) + { + return new LinkBuilder(url); + } + + // See TestResponseGenerator in RoutingWebSite for the code that generates this data. + private class RoutingResult + { + public string[] ExpectedUrls { get; set; } + + public string ActualUrl { get; set; } + + public Dictionary RouteValues { get; set; } + + public string RouteName { get; set; } + + public string Action { get; set; } + + public string Controller { get; set; } + + public string Link { get; set; } + } + + private class LinkBuilder + { + public LinkBuilder(string url) + { + Url = url; + + Values = new Dictionary(); + Values.Add("link", string.Empty); + } + + public string Url { get; set; } + + public Dictionary Values { get; set; } + + public LinkBuilder To(object values) + { + var dictionary = new RouteValueDictionary(values); + foreach (var kvp in dictionary) + { + Values.Add("link_" + kvp.Key, kvp.Value); + } + + return this; + } + + public override string ToString() + { + return Url + "?" + string.Join("&", Values.Select(kvp => kvp.Key + "=" + kvp.Value)); + } + + public static implicit operator string(LinkBuilder builder) + { + return builder.ToString(); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj index d9bafa174e..a3d5754be1 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj @@ -31,6 +31,7 @@ + diff --git a/test/WebSites/DispatchingWebSite/Areas/Admin/UserManagementController.cs b/test/WebSites/DispatchingWebSite/Areas/Admin/UserManagementController.cs new file mode 100644 index 0000000000..9d58e35998 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Areas/Admin/UserManagementController.cs @@ -0,0 +1,25 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Admin +{ + [Area("Admin")] + [Route("[area]/Users")] + public class UserManagementController : Controller + { + private readonly TestResponseGenerator _generator; + + public UserManagementController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet("All")] + public IActionResult ListUsers() + { + return _generator.Generate("Admin/Users/All"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Areas/Order/OrderController.cs b/test/WebSites/DispatchingWebSite/Areas/Order/OrderController.cs new file mode 100644 index 0000000000..76e83adf58 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Areas/Order/OrderController.cs @@ -0,0 +1,25 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Areas.Order +{ + [Area("Order")] + [Route("Order/[action]", Name = "[area]_[action]")] + public class OrderController : Controller + { + private readonly TestResponseGenerator _generator; + + public OrderController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet] + public IActionResult GetOrder() + { + return _generator.Generate("/Order/GetOrder"); + } + } +} diff --git a/test/WebSites/DispatchingWebSite/Areas/Travel/FlightController.cs b/test/WebSites/DispatchingWebSite/Areas/Travel/FlightController.cs new file mode 100644 index 0000000000..adaf6e46cc --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Areas/Travel/FlightController.cs @@ -0,0 +1,30 @@ +// 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.Mvc; + +namespace DispatchingWebSite +{ + // This controller is reachable via traditional routing. + [Area("Travel")] + public class FlightController + { + private readonly TestResponseGenerator _generator; + + public FlightController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult Index() + { + return _generator.Generate("/Travel/Flight", "/Travel/Flight/Index"); + } + + [HttpPost] + public IActionResult BuyTickets() + { + return _generator.Generate("/Travel/Flight/BuyTickets"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Areas/Travel/HomeController.cs b/test/WebSites/DispatchingWebSite/Areas/Travel/HomeController.cs new file mode 100644 index 0000000000..51b20ea566 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Areas/Travel/HomeController.cs @@ -0,0 +1,29 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Travel +{ + [Area("Travel")] + public class HomeController : Controller + { + private readonly TestResponseGenerator _generator; + + public HomeController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult Index() + { + return _generator.Generate("/Travel", "/Travel/Home", "/Travel/Home/Index"); + } + + [HttpGet("ContosoCorp/AboutTravel")] + public IActionResult About() + { + return _generator.Generate(); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Areas/Travel/RailController.cs b/test/WebSites/DispatchingWebSite/Areas/Travel/RailController.cs new file mode 100644 index 0000000000..c12bcd8c6f --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Areas/Travel/RailController.cs @@ -0,0 +1,30 @@ +// 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.Mvc; + +namespace DispatchingWebSite +{ + [Area("Travel")] + [Route("ContosoCorp/Trains")] + public class RailController + { + private readonly TestResponseGenerator _generator; + + public RailController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult Index() + { + return _generator.Generate("/ContosoCorp/Trains"); + } + + [HttpGet("CheckSchedule")] + public IActionResult Schedule() + { + return _generator.Generate("/ContosoCorp/Trains/Schedule"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/BanksController.cs b/test/WebSites/DispatchingWebSite/Controllers/BanksController.cs new file mode 100644 index 0000000000..832fb4d8ed --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/BanksController.cs @@ -0,0 +1,52 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + public class BanksController : Controller + { + private readonly TestResponseGenerator _generator; + + public BanksController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet("Banks/[action]/{id}")] + [HttpGet("Bank/[action]/{id}")] + public ActionResult Get(int id) + { + return _generator.Generate( + Url.Action(), + Url.RouteUrl(new { })); + } + + [AcceptVerbs("PUT", Route = "Bank")] + [HttpPatch("Bank")] + [AcceptVerbs("PUT", Route = "Bank/Update")] + [HttpPatch("Bank/Update")] + public ActionResult UpdateBank() + { + return _generator.Generate( + Url.Action(), + Url.RouteUrl(new { })); + } + + [AcceptVerbs("PUT", "POST")] + [Route("Bank/Deposit")] + [Route("Bank/Deposit/{amount}")] + public ActionResult Deposit() + { + return _generator.Generate("/Bank/Deposit", "/Bank/Deposit/5"); + } + + [HttpPost] + [Route("Bank/Withdraw/{id}")] + public ActionResult Withdraw(int id) + { + return _generator.Generate("/Bank/Withdraw/5"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/BlogController.cs b/test/WebSites/DispatchingWebSite/Controllers/BlogController.cs new file mode 100644 index 0000000000..b48a9c05b4 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/BlogController.cs @@ -0,0 +1,29 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + // This controller contains actions mapped with a single controller-level route. + [Route("Blog/[action]/{postId?}")] + public class BlogController + { + private readonly TestResponseGenerator _generator; + + public BlogController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult ShowPosts() + { + return _generator.Generate("/Blog/ShowPosts"); + } + + public IActionResult Edit(int postId) + { + return _generator.Generate("/Blog/Edit/" + postId); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/CompanyController.cs b/test/WebSites/DispatchingWebSite/Controllers/CompanyController.cs new file mode 100644 index 0000000000..cc45b934ef --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/CompanyController.cs @@ -0,0 +1,63 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + // A controller can define a route for all of the actions + // in it and give it a name for link generation purposes. + [Route("api/Company/{id}", Name = "Company")] + public class CompanyController : Controller + { + private readonly TestResponseGenerator _generator; + + public CompanyController(TestResponseGenerator generator) + { + _generator = generator; + } + + // An action with the same template will inherit the name + // from the controller. + [HttpGet] + public ActionResult Get(int id) + { + return _generator.Generate(Url.RouteUrl("Company", new { id = id })); + } + + // Multiple actions can have the same named route as long + // as for a given Name, all the actions have the same template. + // That is, there can't be two link generation entries with same + // name and different templates. + [HttpPut] + public ActionResult Put(int id) + { + return _generator.Generate(Url.RouteUrl("Company", new { id = id })); + } + + // Two actions can have the same template and each of them can have + // a different route name. That is, a given template can have multiple + // names associated with it. + [HttpDelete(Name = "RemoveCompany")] + public ActionResult Delete(int id) + { + return _generator.Generate(Url.RouteUrl("RemoveCompany", new { id = id })); + } + + // An action that defines a non empty template doesn't inherit the name + // from the route on the controller . + [HttpGet("Employees")] + public ActionResult GetEmployees(int id) + { + return _generator.Generate(Url.RouteUrl(new { id = id })); + } + + // An action that defines a non empty template doesn't inherit the name + // from the controller but can perfectly define its own name. + [HttpGet("Departments", Name = "Departments")] + public ActionResult GetDepartments(int id) + { + return _generator.Generate(Url.RouteUrl("Departments", new { id = id })); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/EmployeeController.cs b/test/WebSites/DispatchingWebSite/Controllers/EmployeeController.cs new file mode 100644 index 0000000000..fd946d4752 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/EmployeeController.cs @@ -0,0 +1,73 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + // This controller combines routes on the controller with routes on actions in a REST + navigation property + // style. + [Route("api/Employee")] + public class EmployeeController : Controller + { + private readonly TestResponseGenerator _generator; + + public EmployeeController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult List() + { + return _generator.Generate("/api/Employee"); + } + + [AcceptVerbs("PUT", "PATCH")] + public IActionResult UpdateEmployee() + { + return _generator.Generate("/api/Employee"); + } + + [AcceptVerbs("PUT", "PATCH", Route = "Manager")] + public IActionResult UpdateManager() + { + return _generator.Generate("/api/Employee/Manager"); + } + + [HttpMerge("{id}")] + public IActionResult MergeEmployee(int id) + { + return _generator.Generate("/api/Employee/" + id); + } + + [HttpGet("{id}")] + public IActionResult Get(int id) + { + return _generator.Generate("/api/Employee/" + id); + } + + [HttpGet("{id}/Administrator")] + public IActionResult GetAdministrator(int id) + { + return _generator.Generate("/api/Employee/" + id + "/Administrator"); + } + + [HttpGet("~/Manager/{id}")] + public IActionResult GetManager(int id) + { + return _generator.Generate("/Manager/" + id); + } + + [HttpDelete("{id}/Administrator")] + public IActionResult DeleteAdministrator(int id) + { + return _generator.Generate("/api/Employee/" + id + "/Administrator"); + } + + [Route("{id}/Salary")] + public IActionResult Salary(int id) + { + return _generator.Generate("/api/Employee/" + id + "/Salary"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/FriendsController.cs b/test/WebSites/DispatchingWebSite/Controllers/FriendsController.cs new file mode 100644 index 0000000000..25c32c8c3f --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/FriendsController.cs @@ -0,0 +1,31 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + [Route("Friends")] + public class FriendsController : Controller + { + private readonly TestResponseGenerator _generator; + + public FriendsController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet] + [HttpGet("{id}")] + public IActionResult Get([FromRoute]string id) + { + return _generator.Generate(id == null ? "/Friends" : $"/Friends/{id}"); + } + + [HttpDelete] + public IActionResult Delete() + { + return _generator.Generate("/Friends"); + } + } +} diff --git a/test/WebSites/DispatchingWebSite/Controllers/HomeController.cs b/test/WebSites/DispatchingWebSite/Controllers/HomeController.cs new file mode 100644 index 0000000000..0b40b54c15 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/HomeController.cs @@ -0,0 +1,39 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + // This controller is reachable via traditional routing. + public class HomeController : Controller + { + private readonly TestResponseGenerator _generator; + + public HomeController(TestResponseGenerator generator) + { + _generator = generator; + } + + public IActionResult Index() + { + return _generator.Generate("/", "/Home", "/Home/Index"); + } + + public IActionResult About() + { + // There are no urls that reach this action - it's hidden by an attribute route. + return _generator.Generate(); + } + + public IActionResult Contact() + { + return _generator.Generate("/Home/Contact"); + } + + public IActionResult OptionalPath(string path = "default") + { + return _generator.Generate("/Home/OptionalPath/" + path); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/MapsController.cs b/test/WebSites/DispatchingWebSite/Controllers/MapsController.cs new file mode 100644 index 0000000000..2b45d3de62 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/MapsController.cs @@ -0,0 +1,53 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + [Route("api/v1/Maps", Name = "v1", Order = 1)] + [Route("api/v2/Maps")] + public class MapsController : Controller + { + private readonly TestResponseGenerator _generator; + + public MapsController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet] + public ActionResult Get() + { + // Multiple attribute routes with name and order. + // We will always generate v2 routes except when + // we explicitly use "v1" to generate a v1 route. + return _generator.Generate( + Url.Action(), + Url.RouteUrl("v1"), + Url.RouteUrl(new { })); + } + + [HttpPost("/api/v2/Maps")] + public ActionResult Post() + { + return _generator.Generate( + Url.Action(), + Url.RouteUrl(new { })); + } + + [HttpPut("{id}")] + [HttpPatch("PartialUpdate/{id}")] + public ActionResult Update(int id) + { + // We will generate "/api/v2/Maps/PartialUpdate/{id}" + // in both cases, v1 routes will be discarded due to their + // Order and for v2 routes PartialUpdate has higher precedence. + // api/v1/Maps/{id} and api/v2/Maps/{id} will only match on PUT. + // api/v1/Maps/PartialUpdate/{id} and api/v2/Maps/PartialUpdate/{id} will only match on PATCH. + return _generator.Generate( + Url.Action(), + Url.RouteUrl(new { })); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/OrderController.cs b/test/WebSites/DispatchingWebSite/Controllers/OrderController.cs new file mode 100644 index 0000000000..195d810af6 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/OrderController.cs @@ -0,0 +1,36 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + [Route("Order/[action]/{orderId?}", Name = "Order_[action]")] + public class OrderController : Controller + { + private readonly TestResponseGenerator _generator; + + public OrderController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet] + public IActionResult Add(int orderId) + { + return _generator.Generate("/Order/Add/1"); + } + + [HttpPost] + public IActionResult Add() + { + return _generator.Generate("/Order/Add"); + } + + [HttpPut] + public IActionResult Edit(int orderId) + { + return _generator.Generate("/Order/Edit/1"); + } + } +} diff --git a/test/WebSites/DispatchingWebSite/Controllers/StoreController.cs b/test/WebSites/DispatchingWebSite/Controllers/StoreController.cs new file mode 100644 index 0000000000..a6b7e679a5 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/StoreController.cs @@ -0,0 +1,43 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + // This controller contains only actions with individual attribute routes. + public class StoreController : Controller + { + private readonly TestResponseGenerator _generator; + + public StoreController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet("Store/Shop/Products")] + public IActionResult ListProducts() + { + return _generator.Generate("/Store/Shop/Products"); + } + + // Intentionally designed to conflict with HomeController#About. + [HttpGet("Home/About")] + public IActionResult About() + { + return _generator.Generate("/Home/About"); + } + + [Route("Store/Shop/Orders")] + public IActionResult Orders() + { + return _generator.Generate("/Store/Shop/Orders"); + } + + [HttpGet("Store/Shop/Orders")] + public IActionResult GetOrders() + { + return _generator.Generate("/Store/Shop/Orders"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Controllers/TeamController.cs b/test/WebSites/DispatchingWebSite/Controllers/TeamController.cs new file mode 100644 index 0000000000..a5459b8e79 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Controllers/TeamController.cs @@ -0,0 +1,72 @@ +// 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.Mvc; + +namespace DispatchingWebSite.Controllers +{ + [Route("/Teams", Order = 1)] + public class TeamController : Controller + { + private readonly TestResponseGenerator _generator; + + public TeamController(TestResponseGenerator generator) + { + _generator = generator; + } + + [HttpGet("/Team/{teamId}", Order = 2)] + public ActionResult GetTeam(int teamId) + { + return _generator.Generate("/Team/" + teamId); + } + + [HttpGet("/Team/{teamId}")] + public ActionResult GetOrganization(int teamId) + { + return _generator.Generate("/Team/" + teamId); + } + + [HttpGet("")] + public ActionResult GetTeams() + { + return _generator.Generate("/Teams"); + } + + [HttpGet("", Order = 0)] + public ActionResult GetOrganizations() + { + return _generator.Generate("/Teams"); + } + + [HttpGet("/Club/{clubId?}")] + public ActionResult GetClub() + { + return Content(Url.Action(), "text/plain"); + } + + [HttpGet("/Organization/{clubId?}", Order = 1)] + public ActionResult GetClub(int clubId) + { + return Content(Url.Action(), "text/plain"); + } + + [HttpGet("AllTeams")] + public ActionResult GetAllTeams() + { + return Content(Url.Action(), "text/plain"); + } + + [HttpGet("AllOrganizations", Order = 0)] + public ActionResult GetAllTeams(int notRelevant) + { + return Content(Url.Action(), "text/plain"); + } + + [HttpGet("/TeamName/{*Name=DefaultName}/")] + public ActionResult GetTeam(string name) + { + return _generator.Generate("/TeamName/" + name); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/DispatchingWebSite.csproj b/test/WebSites/DispatchingWebSite/DispatchingWebSite.csproj new file mode 100644 index 0000000000..df863f0a9c --- /dev/null +++ b/test/WebSites/DispatchingWebSite/DispatchingWebSite.csproj @@ -0,0 +1,15 @@ + + + + $(StandardTestWebsiteTfms) + + + + + + + + + + + diff --git a/test/WebSites/DispatchingWebSite/HttpMergeAttribute.cs b/test/WebSites/DispatchingWebSite/HttpMergeAttribute.cs new file mode 100644 index 0000000000..19477e4889 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/HttpMergeAttribute.cs @@ -0,0 +1,34 @@ +// 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; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace DispatchingWebSite +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class HttpMergeAttribute : Attribute, IActionHttpMethodProvider, IRouteTemplateProvider + { + private static readonly IEnumerable _supportedMethods = new[] { "MERGE" }; + + public HttpMergeAttribute(string template) + { + Template = template; + } + + public IEnumerable HttpMethods + { + get { return _supportedMethods; } + } + + /// + public string Template { get; private set; } + + /// + public int? Order { get; set; } + + /// + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/Startup.cs b/test/WebSites/DispatchingWebSite/Startup.cs new file mode 100644 index 0000000000..f576ad29d3 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/Startup.cs @@ -0,0 +1,78 @@ +// 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; +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace DispatchingWebSite +{ + public class Startup + { + // Set up application services + public void ConfigureServices(IServiceCollection services) + { + services.AddDispatcher(); + + services.AddMvc(); + + services.AddScoped(); + services.AddSingleton(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseDispatcher(); + + //app.UseMvc(routes => + //{ + // routes.MapAreaRoute( + // "flightRoute", + // "adminRoute", + // "{area:exists}/{controller}/{action}", + // new { controller = "Home", action = "Index" }, + // new { area = "Travel" }); + + // routes.MapRoute( + // "ActionAsMethod", + // "{controller}/{action}", + // defaults: new { controller = "Home", action = "Index" }); + + // routes.MapRoute( + // "RouteWithOptionalSegment", + // "{controller}/{action}/{path?}"); + //}); + + app.UseEndpoint(); + } + + public static void Main(string[] args) + { + var host = CreateWebHostBuilder(args) + .ConfigureLogging((hostingContext, logging) => + { + logging.AddConsole(); + }) + .Build(); + + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseKestrel() + .UseIISIntegration(); + } +} + diff --git a/test/WebSites/DispatchingWebSite/TestResponseGenerator.cs b/test/WebSites/DispatchingWebSite/TestResponseGenerator.cs new file mode 100644 index 0000000000..41f9455f66 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/TestResponseGenerator.cs @@ -0,0 +1,61 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace DispatchingWebSite +{ + // Generates a response based on the expected URL and action context + public class TestResponseGenerator + { + private readonly ActionContext _actionContext; + private readonly IUrlHelperFactory _urlHelperFactory; + + public TestResponseGenerator(IActionContextAccessor contextAccessor, IUrlHelperFactory urlHelperFactory) + { + _urlHelperFactory = urlHelperFactory; + + _actionContext = contextAccessor.ActionContext; + if (_actionContext == null) + { + throw new InvalidOperationException("ActionContext should not be null here."); + } + } + + public JsonResult Generate(params string[] expectedUrls) + { + var link = (string)null; + var query = _actionContext.HttpContext.Request.Query; + if (query.ContainsKey("link")) + { + var values = query + .Where(kvp => kvp.Key != "link" && kvp.Key != "link_action" && kvp.Key != "link_controller") + .ToDictionary(kvp => kvp.Key.Substring("link_".Length), kvp => (object)kvp.Value[0]); + + var urlHelper = _urlHelperFactory.GetUrlHelper(_actionContext); + link = urlHelper.Action(query["link_action"], query["link_controller"], values); + } + + var attributeRoutingInfo = _actionContext.ActionDescriptor.AttributeRouteInfo; + + return new JsonResult(new + { + expectedUrls = expectedUrls, + actualUrl = _actionContext.HttpContext.Request.Path.Value, + routeName = attributeRoutingInfo == null ? null : attributeRoutingInfo.Name, + routeValues = new Dictionary(_actionContext.RouteData.Values), + + action = ((ControllerActionDescriptor) _actionContext.ActionDescriptor).ActionName, + controller = ((ControllerActionDescriptor)_actionContext.ActionDescriptor).ControllerName, + + link, + }); + } + } +} \ No newline at end of file diff --git a/test/WebSites/DispatchingWebSite/readme.md b/test/WebSites/DispatchingWebSite/readme.md new file mode 100644 index 0000000000..357e09a324 --- /dev/null +++ b/test/WebSites/DispatchingWebSite/readme.md @@ -0,0 +1,4 @@ +DispatchingWebSite +=== + +This web site illustrates how to use conventional and attribute dispatching.