Support page parameter in attribute route (#8530)

This commit is contained in:
James Newton-King 2018-10-04 14:39:40 +13:00 committed by GitHub
parent 70ddf15cbc
commit 7854d65c11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 383 additions and 143 deletions

View File

@ -268,24 +268,33 @@ namespace Microsoft.AspNetCore.Mvc.Internal
allParameterPolicies = MvcEndpointInfo.BuildParameterPolicies(routePattern.Parameters, _parameterPolicyFactory);
}
// Replace parameter with literal value
var parameterRouteValue = action.RouteValues[parameterPart.Name];
// Replace parameter with literal value
if (allParameterPolicies.TryGetValue(parameterPart.Name, out var parameterPolicies))
// Route value could be null if it is a "known" route value.
// Do not use the null value to de-normalize the route pattern,
// instead leave the parameter unchanged.
// e.g.
// RouteValues will contain a null "page" value if there are Razor pages
// Skip replacing the {page} parameter
if (parameterRouteValue != null)
{
// Check if the parameter has a transformer policy
// Use the first transformer policy
for (var k = 0; k < parameterPolicies.Count; k++)
if (allParameterPolicies.TryGetValue(parameterPart.Name, out var parameterPolicies))
{
if (parameterPolicies[k] is IOutboundParameterTransformer parameterTransformer)
// Check if the parameter has a transformer policy
// Use the first transformer policy
for (var k = 0; k < parameterPolicies.Count; k++)
{
parameterRouteValue = parameterTransformer.TransformOutbound(parameterRouteValue);
break;
if (parameterPolicies[k] is IOutboundParameterTransformer parameterTransformer)
{
parameterRouteValue = parameterTransformer.TransformOutbound(parameterRouteValue);
break;
}
}
}
}
segmentParts[j] = RoutePatternFactory.LiteralPart(parameterRouteValue);
segmentParts[j] = RoutePatternFactory.LiteralPart(parameterRouteValue);
}
}
}

View File

@ -251,6 +251,42 @@ namespace Microsoft.AspNetCore.Mvc.Internal
Assert.Collection(endpoints, inspectors);
}
[Fact]
public void Endpoints_SingleAction_ConventionalRoute_ContainsParameterWithNullRequiredRouteValue()
{
// Arrange
var actionDescriptorCollection = GetActionDescriptorCollection(
new { controller = "TestController", action = "TestAction", page = (string)null });
var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection);
dataSource.ConventionalEndpointInfos.Add(CreateEndpointInfo(
string.Empty,
"{controller}/{action}/{page}",
new RouteValueDictionary(new { action = "TestAction" })));
// Act
var endpoints = dataSource.Endpoints;
// Assert
Assert.Empty(endpoints);
}
[Fact]
public void Endpoints_SingleAction_AttributeRoute_ContainsParameterWithNullRequiredRouteValue()
{
// Arrange
var actionDescriptorCollection = GetActionDescriptorCollection(
"{controller}/{action}/{page}",
new { controller = "TestController", action = "TestAction", page = (string)null });
var dataSource = CreateMvcEndpointDataSource(actionDescriptorCollection);
// Act
var endpoints = dataSource.Endpoints;
// Assert
Assert.Collection(endpoints,
(e) => Assert.Equal("TestController/TestAction/{page}", Assert.IsType<RouteEndpoint>(e).RoutePattern.RawText));
}
[Fact]
public void Endpoints_SingleAction_WithActionDefault()
{

View File

@ -0,0 +1,47 @@
// 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.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc
{
public class LinkBuilder
{
public LinkBuilder(string url)
{
Url = url;
Values = new Dictionary<string, object>
{
{ "link", string.Empty }
};
}
public string Url { get; set; }
public Dictionary<string, object> 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();
}
}
}

View File

@ -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 System.Collections.Generic;
namespace Microsoft.AspNetCore.Mvc
{
// See TestResponseGenerator for the code that generates this data.
public class RoutingResult
{
public string[] ExpectedUrls { get; set; }
public string ActualUrl { get; set; }
public Dictionary<string, object> RouteValues { get; set; }
public string RouteName { get; set; }
public string Action { get; set; }
public string Controller { get; set; }
public string Link { get; set; }
}
}

View File

@ -382,7 +382,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var expected = "ConventionalRoute - Hello from mypage";
// Act
var response = await Client.GetStringAsync("/PageRoute/ConventionalRoute/mypage");
var response = await Client.GetStringAsync("/PageRoute/ConventionalRouteView/mypage");
// Assert
Assert.Equal(expected, response.Trim());
@ -395,7 +395,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var expected = "AttributeRoute - Hello from test-page";
// Act
var response = await Client.GetStringAsync("/PageRoute/Attribute/test-page");
var response = await Client.GetStringAsync("/PageRoute/AttributeView/test-page");
// Assert
Assert.Equal(expected, response.Trim());

View File

@ -10,7 +10,6 @@
<ItemGroup>
<Compile Include="..\Microsoft.AspNetCore.Mvc.Formatters.Xml.Test\XmlAssert.cs" />
<Compile Include="..\Microsoft.AspNetCore.Mvc.Core.TestCommon\ActivityReplacer.cs" />
<EmbeddedResource Include="compiler\resources\**\*" />
<None Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
@ -29,6 +28,8 @@
<ProjectReference Include="..\..\benchmarkapps\BasicViews\BasicViews.csproj" />
<ProjectReference Include="..\..\samples\MvcSandbox\MvcSandbox.csproj" />
<ProjectReference Include="..\Microsoft.AspNetCore.Mvc.Core.TestCommon\Microsoft.AspNetCore.Mvc.Core.TestCommon.csproj" />
<ProjectReference Include="..\WebSites\ApiExplorerWebSite\ApiExplorerWebSite.csproj" />
<ProjectReference Include="..\WebSites\ApplicationModelWebSite\ApplicationModelWebSite.csproj" />
<ProjectReference Include="..\WebSites\BasicWebSite\BasicWebSite.csproj" />

View File

@ -2,6 +2,7 @@
// 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.Net;
using System.Net.Http;
using System.Threading.Tasks;
@ -10,13 +11,34 @@ using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class EndpointRoutingTest : RoutingTestsBase<RoutingWebSite.Startup>
public class RoutingEndpointRoutingTest : RoutingTestsBase<RoutingWebSite.Startup>
{
public EndpointRoutingTest(MvcTestFixture<RoutingWebSite.Startup> fixture)
public RoutingEndpointRoutingTest(MvcTestFixture<RoutingWebSite.Startup> fixture)
: base(fixture)
{
}
[Fact]
public async Task AttributeRoutedAction_ContainsPage_RouteMatched()
{
// Arrange & Act
var response = await Client.GetAsync("http://localhost/PageRoute/Attribute/pagevalue");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<RoutingResult>(body);
Assert.Contains("/PageRoute/Attribute/pagevalue", result.ExpectedUrls);
Assert.Equal("PageRoute", result.Controller);
Assert.Equal("AttributeRoute", result.Action);
Assert.Contains(
new KeyValuePair<string, object>("page", "pagevalue"),
result.RouteValues);
}
[Fact]
public async Task ParameterTransformer_TokenReplacement_Found()
{

View File

@ -0,0 +1,19 @@
// 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.Threading.Tasks;
using Newtonsoft.Json;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class RoutingEndpointRoutingWithoutRazorPagesTests : RoutingWithoutRazorPagesTestsBase<BasicWebSite.StartupWithEndpointRouting>
{
public RoutingEndpointRoutingWithoutRazorPagesTests(MvcTestFixture<BasicWebSite.StartupWithEndpointRouting> fixture)
: base(fixture)
{
}
}
}

View File

@ -27,6 +27,25 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
public HttpClient Client { get; }
[Fact]
public async Task ConventionalRoutedAction_RouteContainsPage_RouteNotMatched()
{
// Arrange & Act
var response = await Client.GetAsync("http://localhost/PageRoute/ConventionalRoute/pagevalue");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<RoutingResult>(body);
Assert.Equal("PageRoute", result.Controller);
Assert.Equal("ConventionalRoute", result.Action);
// pagevalue is not used in "page" route value because it is a required value
Assert.False(result.RouteValues.ContainsKey("page"));
}
[Fact]
public abstract Task HasEndpointMatch();
@ -1282,61 +1301,5 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
return new LinkBuilder(url);
}
// See TestResponseGenerator in RoutingWebSite for the code that generates this data.
protected class RoutingResult
{
public string[] ExpectedUrls { get; set; }
public string ActualUrl { get; set; }
public Dictionary<string, object> RouteValues { get; set; }
public string RouteName { get; set; }
public string Action { get; set; }
public string Controller { get; set; }
public string Link { get; set; }
}
protected class LinkBuilder
{
public LinkBuilder(string url)
{
Url = url;
Values = new Dictionary<string, object>
{
{ "link", string.Empty }
};
}
public string Url { get; set; }
public Dictionary<string, object> 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();
}
}
}
}

View File

@ -0,0 +1,19 @@
// 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.Threading.Tasks;
using Newtonsoft.Json;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class RoutingWithoutRazorPagesTests : RoutingWithoutRazorPagesTestsBase<BasicWebSite.Startup>
{
public RoutingWithoutRazorPagesTests(MvcTestFixture<BasicWebSite.Startup> fixture)
: base(fixture)
{
}
}
}

View File

@ -0,0 +1,68 @@
// 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.Hosting;
using Newtonsoft.Json;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public abstract class RoutingWithoutRazorPagesTestsBase<TStartup> : IClassFixture<MvcTestFixture<TStartup>> where TStartup : class
{
protected RoutingWithoutRazorPagesTestsBase(MvcTestFixture<TStartup> fixture)
{
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
Client = factory.CreateDefaultClient();
}
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) =>
builder.UseStartup<TStartup>();
public HttpClient Client { get; }
[Fact]
public async Task AttributeRoutedAction_ContainsPage_RouteMatched()
{
// Arrange & Act
var response = await Client.GetAsync("http://localhost/PageRoute/Attribute/pagevalue");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<RoutingResult>(body);
Assert.Contains("/PageRoute/Attribute/pagevalue", result.ExpectedUrls);
Assert.Equal("PageRoute", result.Controller);
Assert.Equal("AttributeRoute", result.Action);
Assert.Contains(
new KeyValuePair<string, object>("page", "pagevalue"),
result.RouteValues);
}
[Fact]
public async Task ConventionalRoutedAction_RouteContainsPage_RouteNotMatched()
{
// Arrange & Act
var response = await Client.GetAsync("http://localhost/PageRoute/ConventionalRoute/pagevalue");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<RoutingResult>(body);
Assert.Equal("PageRoute", result.Controller);
Assert.Equal("ConventionalRoute", result.Action);
Assert.Equal("pagevalue", result.RouteValues["page"]);
}
}
}

View File

@ -4,6 +4,10 @@
<TargetFrameworks>$(StandardTestWebsiteTfms)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Common\TestResponseGenerator.cs" Link="TestResponseGenerator.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.AspNetCore.Mvc\Microsoft.AspNetCore.Mvc.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.AspNetCore.Mvc.Formatters.Xml\Microsoft.AspNetCore.Mvc.Formatters.Xml.csproj" />

View File

@ -9,14 +9,32 @@ namespace BasicWebSite.Controllers
// without affecting view lookups.
public class PageRouteController : Controller
{
private readonly TestResponseGenerator _generator;
public PageRouteController(TestResponseGenerator generator)
{
_generator = generator;
}
public IActionResult ConventionalRoute(string page)
{
return _generator.Generate("/PageRoute/ConventionalRoute/" + page);
}
[HttpGet("/PageRoute/Attribute/{page}")]
public IActionResult AttributeRoute(string page)
{
return _generator.Generate("/PageRoute/Attribute/" + page);
}
public IActionResult ConventionalRouteView(string page)
{
ViewData["page"] = page;
return View();
}
[HttpGet("/PageRoute/Attribute/{page}")]
public IActionResult AttributeRoute(string page)
[HttpGet("/PageRoute/AttributeView/{page}")]
public IActionResult AttributeRouteView(string page)
{
ViewData["page"] = page;
return View();

View File

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace BasicWebSite
@ -42,6 +43,8 @@ namespace BasicWebSite
services.AddSingleton<ContactsRepository>();
services.AddScoped<RequestIdService>();
services.AddTransient<ServiceActionFilter>();
services.AddScoped<TestResponseGenerator>();
services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
}
public void Configure(IApplicationBuilder app)

View File

@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace BasicWebSite
@ -22,6 +23,8 @@ namespace BasicWebSite
services.AddHttpContextAccessor();
services.AddScoped<RequestIdService>();
services.AddScoped<TestResponseGenerator>();
services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
}
public void Configure(IApplicationBuilder app)
@ -35,6 +38,8 @@ namespace BasicWebSite
"ActionAsMethod",
"{controller}/{action}",
defaults: new { controller = "Home", action = "Index" });
routes.MapRoute("PageRoute", "{controller}/{action}/{page}");
});
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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;
@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace VersioningWebSite
namespace Microsoft.AspNetCore.Mvc
{
// Generates a response based on the expected URL and action context
public class TestResponseGenerator
@ -63,4 +63,4 @@ namespace VersioningWebSite
return urlHelper;
}
}
}
}

View File

@ -0,0 +1,28 @@
// 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 RoutingWebSite
{
public class PageRouteController
{
private readonly TestResponseGenerator _generator;
public PageRouteController(TestResponseGenerator generator)
{
_generator = generator;
}
public IActionResult ConventionalRoute(string page)
{
return _generator.Generate("/PageRoute/ConventionalRoute/" + page);
}
[HttpGet("/PageRoute/Attribute/{page}")]
public IActionResult AttributeRoute(string page)
{
return _generator.Generate("/PageRoute/Attribute/" + page);
}
}
}

View File

@ -10,11 +10,11 @@ namespace RoutingWebSite
{
public class RemoveControllerActionDescriptorProvider : IActionDescriptorProvider
{
private readonly Type _controllerType;
private readonly ControllerToRemove[] _controllerTypes;
public RemoveControllerActionDescriptorProvider(Type controllerType)
public RemoveControllerActionDescriptorProvider(params ControllerToRemove[] controllerTypes)
{
_controllerType = controllerType;
_controllerTypes = controllerTypes;
}
public int Order => int.MaxValue;
@ -29,12 +29,22 @@ namespace RoutingWebSite
{
if (item is ControllerActionDescriptor controllerActionDescriptor)
{
if (controllerActionDescriptor.ControllerTypeInfo == _controllerType)
var controllerToRemove = _controllerTypes.SingleOrDefault(c => c.ControllerType == controllerActionDescriptor.ControllerTypeInfo);
if (controllerToRemove != null)
{
context.Results.Remove(item);
if (controllerToRemove.Actions == null || controllerToRemove.Actions.Contains(controllerActionDescriptor.ActionName))
{
context.Results.Remove(item);
}
}
}
}
}
}
public class ControllerToRemove
{
public Type ControllerType { get; set; }
public string[] Actions { get; set; }
}
}

View File

@ -4,6 +4,10 @@
<TargetFrameworks>$(StandardTestWebsiteTfms)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Common\TestResponseGenerator.cs" Link="TestResponseGenerator.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.AspNetCore.Mvc\Microsoft.AspNetCore.Mvc.csproj" />

View File

@ -58,6 +58,12 @@ namespace RoutingWebSite
defaults: new { controller = "Home", action = "Index" },
constraints: new { area = "Travel" });
routes.MapRoute(
"PageRoute",
"{controller}/{action}/{page}",
defaults: null,
constraints: new { controller = "PageRoute" });
routes.MapRoute(
"ActionAsMethod",
"{controller}/{action}",

View File

@ -28,7 +28,17 @@ namespace RoutingWebSite
// EndpointRoutingController is not compatible with old routing
// Remove its action to avoid errors
var actionDescriptorProvider = new RemoveControllerActionDescriptorProvider(typeof(EndpointRoutingController));
var actionDescriptorProvider = new RemoveControllerActionDescriptorProvider(
new ControllerToRemove
{
ControllerType = typeof(EndpointRoutingController),
Actions = null, // remove all
},
new ControllerToRemove
{
ControllerType = typeof(PageRouteController),
Actions = new [] { nameof(PageRouteController.AttributeRoute) }
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<IActionDescriptorProvider>(actionDescriptorProvider));
}

View File

@ -1,61 +0,0 @@
// 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 RoutingWebSite
{
// 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<string, object>(_actionContext.RouteData.Values),
action = ((ControllerActionDescriptor) _actionContext.ActionDescriptor).ActionName,
controller = ((ControllerActionDescriptor)_actionContext.ActionDescriptor).ControllerName,
link,
});
}
}
}

View File

@ -4,6 +4,10 @@
<TargetFrameworks>$(StandardTestWebsiteTfms)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Common\TestResponseGenerator.cs" Link="TestResponseGenerator.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.AspNetCore.Mvc\Microsoft.AspNetCore.Mvc.csproj" />