From 745239f09f559ae6d6d5b3d8fbecad29f625bf31 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Tue, 10 Jun 2014 14:16:13 -0700 Subject: [PATCH] Adding Attribute Routing Link Generation --- samples/MvcSample.Web/SimpleRest.cs | 6 + .../ActionDescriptor.cs | 8 +- .../Microsoft.AspNet.Mvc.Core.kproj | 3 +- .../MvcRouteHandler.cs | 11 + .../ReflectedActionDescriptorProvider.cs | 61 ++- .../Routing/AttributeRoute.cs | 143 +++++- .../Routing/AttributeRouteGenerationEntry.cs | 51 ++ ...ntry.cs => AttributeRouteMatchingEntry.cs} | 9 +- .../Routing/AttributeRouting.cs | 102 +++- .../Microsoft.AspNet.Mvc.Core.Test.kproj | 1 + .../Routing/AttributeRouteTests.cs | 409 ++++++++++++++++ .../RoutingTests.cs | 455 +++++++++++++++++- .../Areas/Admin/UserManagementController.cs | 25 + .../Areas/Travel/FlightController.cs | 6 +- .../Areas/Travel/HomeController.cs | 29 ++ .../Areas/Travel/RailController.cs | 30 ++ .../Controllers/HomeController.cs | 5 + .../RoutingWebSite/RoutingWebSite.kproj | 3 + .../RoutingWebSite/TestResponseGenerator.cs | 15 + 19 files changed, 1298 insertions(+), 74 deletions(-) create mode 100644 src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteGenerationEntry.cs rename src/Microsoft.AspNet.Mvc.Core/Routing/{AttributeRouteEntry.cs => AttributeRouteMatchingEntry.cs} (77%) create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTests.cs create mode 100644 test/WebSites/RoutingWebSite/Areas/Admin/UserManagementController.cs create mode 100644 test/WebSites/RoutingWebSite/Areas/Travel/HomeController.cs create mode 100644 test/WebSites/RoutingWebSite/Areas/Travel/RailController.cs diff --git a/samples/MvcSample.Web/SimpleRest.cs b/samples/MvcSample.Web/SimpleRest.cs index 643323b7b2..3f84c8cdd6 100644 --- a/samples/MvcSample.Web/SimpleRest.cs +++ b/samples/MvcSample.Web/SimpleRest.cs @@ -16,5 +16,11 @@ namespace MvcSample.Web { return "Get other thing"; } + + [HttpGet("Link")] + public string GenerateLink(string action = null, string controller = null) + { + return Url.Action(action, controller); + } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs index 00db2d6948..3b19745760 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ActionDescriptor.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; -using Microsoft.AspNet.Mvc.Routing; namespace Microsoft.AspNet.Mvc { @@ -13,7 +12,12 @@ namespace Microsoft.AspNet.Mvc public List RouteConstraints { get; set; } /// - /// The route template May be null if the action has no attribute routes. + /// The set of route values that are added when this action is selected. + /// + public Dictionary RouteValues { get; set; } + + /// + /// The route template. May be null if the action has no attribute routes. /// public string RouteTemplate { get; set; } diff --git a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj index 65a6bf1e91..a507eb91c8 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj +++ b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj @@ -217,7 +217,8 @@ - + + diff --git a/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs b/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs index 9ec34c516b..c9f3521a0e 100644 --- a/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs +++ b/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs @@ -38,6 +38,17 @@ namespace Microsoft.AspNet.Mvc return; } + if (actionDescriptor.RouteValues != null) + { + foreach (var kvp in actionDescriptor.RouteValues) + { + if (!context.RouteData.Values.ContainsKey(kvp.Key)) + { + context.RouteData.Values.Add(kvp.Key, kvp.Value); + } + } + } + var actionContext = new ActionContext(context.HttpContext, context.RouteData, actionDescriptor); var contextAccessor = services.GetService>(); diff --git a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs index 27c211f16e..af69f2a6bb 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs +++ b/src/Microsoft.AspNet.Mvc.Core/ReflectedActionDescriptorProvider.cs @@ -211,26 +211,41 @@ namespace Microsoft.AspNet.Mvc } else { - // An attribute routed action will ignore conventional routed constraints. - actionDescriptor.RouteConstraints.Clear(); - - // TODO #738 - this currently has parity with what we did in MVC5 for the action - // route values. This needs to be reconsidered as part of #738. - var template = TemplateParser.Parse(templateText, _constraintResolver); - if (template.Parameters.Any( - p => p.IsParameter && - string.Equals(p.Name, "action", StringComparison.OrdinalIgnoreCase))) + // An attribute routed action will ignore conventional routed constraints. We still + // want to provide these values as ambient values. + var ambientValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var constraint in actionDescriptor.RouteConstraints) { - actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( - "action", - action.ActionName)); + ambientValues.Add(constraint.RouteKey, constraint.RouteValue); + } + + actionDescriptor.RouteValues = ambientValues; + + // TODO #738 - this currently has parity with what we did in MVC5 when a template uses parameters + // like 'area', 'controller', and 'action. This needs to be reconsidered as part of #738. + // + // For instance, consider actions mapped with api/Blog/{action}. The value of {action} needs to + // passed to action selection to choose the right action. + var template = TemplateParser.Parse(templateText, _constraintResolver); + + var routeConstraints = new List(); + foreach (var constraint in actionDescriptor.RouteConstraints) + { + if (template.Parameters.Any( + p => p.IsParameter && + string.Equals(p.Name, constraint.RouteKey, StringComparison.OrdinalIgnoreCase))) + { + routeConstraints.Add(constraint); + } } var routeGroup = routeGroupsByTemplate[templateText]; - actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( + routeConstraints.Add(new RouteDataActionConstraint( AttributeRouting.RouteGroupKey, routeGroup)); + actionDescriptor.RouteConstraints = routeConstraints; + actionDescriptor.RouteTemplate = templateText; } } @@ -250,11 +265,21 @@ namespace Microsoft.AspNet.Mvc { foreach (var key in removalConstraints) { - if (!HasConstraint(actionDescriptor.RouteConstraints, key)) + if (actionDescriptor.RouteTemplate == null) { - actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( - key, - RouteKeyHandling.DenyKey)); + if (!HasConstraint(actionDescriptor.RouteConstraints, key)) + { + actionDescriptor.RouteConstraints.Add(new RouteDataActionConstraint( + key, + RouteKeyHandling.DenyKey)); + } + } + else + { + if (!actionDescriptor.RouteValues.ContainsKey(key)) + { + actionDescriptor.RouteValues.Add(key, null); + } } } } @@ -282,4 +307,4 @@ namespace Microsoft.AspNet.Mvc return groupsByTemplate; } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs index d1f2f890a4..a05ab1866d 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRoute.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -15,26 +16,34 @@ namespace Microsoft.AspNet.Mvc.Routing public class AttributeRoute : IRouter { private readonly IRouter _next; - private readonly TemplateRoute[] _routes; + private readonly TemplateRoute[] _matchingRoutes; + private readonly AttributeRouteGenerationEntry[] _generationEntries; /// /// Creates a new . /// /// The next router. Invoked when a route entry matches. /// The set of route entries. - public AttributeRoute([NotNull] IRouter next, [NotNull] IEnumerable entries) + public AttributeRoute( + [NotNull] IRouter next, + [NotNull] IEnumerable matchingEntries, + [NotNull] IEnumerable generationEntries) { _next = next; // FOR RIGHT NOW - this is just an array of regular template routes. We'll follow up by implementing - // a good data-structure here. - _routes = entries.OrderBy(e => e.Precedence).Select(e => e.Route).ToArray(); + // a good data-structure here. See #740 + _matchingRoutes = matchingEntries.OrderBy(e => e.Precedence).Select(e => e.Route).ToArray(); + + // FOR RIGHT NOW - this is just an array of binders. We'll follow up by implementing + // a good data-structure here. See #741 + _generationEntries = generationEntries.OrderBy(e => e.Precedence).ToArray(); } /// public async Task RouteAsync([NotNull] RouteContext context) { - foreach (var route in _routes) + foreach (var route in _matchingRoutes) { await route.RouteAsync(context); if (context.IsHandled) @@ -47,9 +56,127 @@ namespace Microsoft.AspNet.Mvc.Routing /// public string GetVirtualPath([NotNull] VirtualPathContext context) { - // Not implemented right now, but we don't want to throw here and block other routes from generating - // a link. + // To generate a link, we iterate the collection of entries (in order of precedence) and execute + // each one that matches the 'required link values' - which will typically be a value for action + // and controller. + // + // Building a proper data structure to optimize this is tracked by #741 + foreach (var entry in _generationEntries) + { + var isMatch = true; + foreach (var requiredLinkValue in entry.RequiredLinkValues) + { + if (!ContextHasSameValue(context, requiredLinkValue.Key, requiredLinkValue.Value)) + { + isMatch = false; + break; + } + } + + if (!isMatch) + { + continue; + } + + var path = GenerateLink(context, entry); + if (path != null) + { + context.IsBound = true; + return path; + } + } + return null; } + + private string GenerateLink(VirtualPathContext context, AttributeRouteGenerationEntry entry) + { + // In attribute the context includes the values that are used to select this entry - typically + // these will be the standard 'action', 'controller' and maybe 'area' tokens. However, we don't + // want to pass these to the link generation code, or else they will end up as query parameters. + // + // So, we need to exclude from here any values that are 'required link values', but aren't + // parameters in the template. + // + // Ex: + // template: api/Products/{action} + // required values: { id = "5", action = "Buy", Controller = "CoolProducts" } + // + // result: { id = "5", action = "Buy" } + var inputValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in context.Values) + { + if (entry.RequiredLinkValues.ContainsKey(kvp.Key)) + { + var parameter = entry.Template.Parameters + .FirstOrDefault(p => string.Equals(p.Name, kvp.Key, StringComparison.OrdinalIgnoreCase)); + + if (parameter == null) + { + continue; + } + } + + inputValues.Add(kvp.Key, kvp.Value); + } + + var bindingResult = entry.Binder.GetValues(context.AmbientValues, inputValues); + if (bindingResult == null) + { + // A required parameter in the template didn't get a value. + return null; + } + + var matched = RouteConstraintMatcher.Match( + entry.Constraints, + bindingResult.CombinedValues, + context.Context, + this, + RouteDirection.UrlGeneration); + if (!matched) + { + // A constrant rejected this link. + return null; + } + + // These values are used to signal to the next route what we would produce if we round-tripped + // (generate a link and then parse). In MVC the 'next route' is typically the MvcRouteHandler. + var providedValues = new Dictionary( + bindingResult.AcceptedValues, + StringComparer.OrdinalIgnoreCase); + providedValues.Add(AttributeRouting.RouteGroupKey, entry.RouteGroup); + + var childContext = new VirtualPathContext(context.Context, context.AmbientValues, context.Values) + { + ProvidedValues = providedValues, + }; + + var path = _next.GetVirtualPath(childContext); + if (path != null) + { + // If path is non-null then the target router short-circuited, we don't expect this + // in typical MVC scenarios. + return path; + } + else if (!childContext.IsBound) + { + // The target router has rejected these values. We don't expect this in typical MVC scenarios. + return null; + } + + path = entry.Binder.BindValues(bindingResult.AcceptedValues); + return path; + } + + private bool ContextHasSameValue(VirtualPathContext context, string key, object value) + { + object providedValue; + if (!context.Values.TryGetValue(key, out providedValue)) + { + context.AmbientValues.TryGetValue(key, out providedValue); + } + + return TemplateBinder.RoutePartsEqual(providedValue, value); + } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteGenerationEntry.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteGenerationEntry.cs new file mode 100644 index 0000000000..9c233de5b7 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteGenerationEntry.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Routing; +using Microsoft.AspNet.Routing.Template; + +namespace Microsoft.AspNet.Mvc.Routing +{ + /// + /// Used to build an . Represents an individual URL-generating route that will be + /// aggregated into the . + /// + public class AttributeRouteGenerationEntry + { + /// + /// The . + /// + public TemplateBinder Binder { get; set; } + + /// + /// The route constraints. + /// + public IDictionary Constraints { get; set; } + + /// + /// The route defaults. + /// + public IDictionary Defaults { get; set; } + + /// + /// The precedence of the template. + /// + public decimal Precedence { get; set; } + + /// + /// The route group. + /// + public string RouteGroup { get; set; } + + /// + /// The set of values that must be present for link genration. + /// + public IDictionary RequiredLinkValues { get; set; } + + /// + /// The . + /// + public Template Template { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteEntry.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteMatchingEntry.cs similarity index 77% rename from src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteEntry.cs rename to src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteMatchingEntry.cs index f82dc084a2..39790523d6 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteEntry.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouteMatchingEntry.cs @@ -1,15 +1,16 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; using Microsoft.AspNet.Routing.Template; namespace Microsoft.AspNet.Mvc.Routing { /// - /// Used to build an . Represents an individual route that will be aggregated - /// into the . + /// Used to build an . Represents an individual URL-matching route that will be + /// aggregated into the . /// - public class AttributeRouteEntry + public class AttributeRouteMatchingEntry { /// /// The precedence of the template. @@ -21,4 +22,4 @@ namespace Microsoft.AspNet.Mvc.Routing /// public TemplateRoute Route { get; set; } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs index 4e7cc43c16..db8cb83f70 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Routing/AttributeRouting.cs @@ -26,38 +26,57 @@ namespace Microsoft.AspNet.Mvc.Routing { var actions = GetActionDescriptors(services); - // We're creating one AttributeRouteEntry per group, so we need to identify the distinct set of - // groups. It's guaranteed that all members of the group have the same template and precedence, - // so we only need to hang on to a single instance of the template. - var routeTemplatesByGroup = GroupTemplatesByGroupId(actions); - var inlineConstraintResolver = services.GetService(); + var routeInfos = GetRouteInfos(actions, inlineConstraintResolver); - var entries = new List(); - foreach (var routeGroup in routeTemplatesByGroup) + // We're creating one AttributeRouteGenerationEntry per action. This allows us to match the intended + // action by expected route values, and then use the TemplateBinder to generate the link. + var generationEntries = new List(); + foreach (var routeInfo in routeInfos) { - var routeGroupId = routeGroup.Key; - var template = routeGroup.Value; + var defaults = routeInfo.ParsedTemplate.Parameters + .Where(p => p.DefaultValue != null) + .ToDictionary(p => p.Name, p => p.DefaultValue, StringComparer.OrdinalIgnoreCase); - var parsedTemplate = TemplateParser.Parse(template, inlineConstraintResolver); - var precedence = AttributeRoutePrecedence.Compute(parsedTemplate); + var constraints = routeInfo.ParsedTemplate.Parameters + .Where(p => p.InlineConstraint != null) + .ToDictionary(p => p.Name, p => p.InlineConstraint, StringComparer.OrdinalIgnoreCase); - entries.Add(new AttributeRouteEntry() + generationEntries.Add(new AttributeRouteGenerationEntry() { - Precedence = precedence, + Binder = new TemplateBinder(routeInfo.ParsedTemplate, defaults), + Defaults = defaults, + Constraints = constraints, + Precedence = routeInfo.Precedence, + RequiredLinkValues = routeInfo.ActionDescriptor.RouteValues, + RouteGroup = routeInfo.RouteGroup, + Template = routeInfo.ParsedTemplate, + }); + } + + // We're creating one AttributeRouteMatchingEntry per group, so we need to identify the distinct set of + // groups. It's guaranteed that all members of the group have the same template and precedence, + // so we only need to hang on to a single instance of the RouteInfo for each group. + var distinctRouteInfosByGroup = GroupRouteInfosByGroupId(routeInfos); + var matchingEntries = new List(); + foreach (var routeInfo in distinctRouteInfosByGroup) + { + matchingEntries.Add(new AttributeRouteMatchingEntry() + { + Precedence = routeInfo.Precedence, Route = new TemplateRoute( target, - template, + routeInfo.RouteTemplate, defaults: new Dictionary(StringComparer.OrdinalIgnoreCase) { - { RouteGroupKey, routeGroupId }, + { RouteGroupKey, routeInfo.RouteGroup }, }, constraints: null, inlineConstraintResolver: inlineConstraintResolver), }); } - return new AttributeRoute(target, entries); + return new AttributeRoute(target, matchingEntries, generationEntries); } private static IReadOnlyList GetActionDescriptors(IServiceProvider services) @@ -68,9 +87,27 @@ namespace Microsoft.AspNet.Mvc.Routing return actionDescriptorsCollection.Items; } - private static Dictionary GroupTemplatesByGroupId(IReadOnlyList actions) + private static IEnumerable GroupRouteInfosByGroupId(List routeInfos) { - var routeTemplatesByGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var routeInfosByGroupId = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var routeInfo in routeInfos) + { + if (!routeInfosByGroupId.ContainsKey(routeInfo.RouteGroup)) + { + routeInfosByGroupId.Add(routeInfo.RouteGroup, routeInfo); + } + } + + return routeInfosByGroupId.Values; + } + + private static List GetRouteInfos( + IReadOnlyList actions, + IInlineConstraintResolver constraintResolver) + { + var routeInfos = new List(); + foreach (var action in actions.Where(a => a.RouteTemplate != null)) { var constraint = action.RouteConstraints @@ -84,14 +121,31 @@ namespace Microsoft.AspNet.Mvc.Routing continue; } - var routeGroup = constraint.RouteValue; - if (!routeTemplatesByGroup.ContainsKey(routeGroup)) + var parsedTemplate = TemplateParser.Parse(action.RouteTemplate, constraintResolver); + routeInfos.Add(new RouteInfo() { - routeTemplatesByGroup.Add(routeGroup, action.RouteTemplate); - } + ActionDescriptor = action, + ParsedTemplate = parsedTemplate, + Precedence = AttributeRoutePrecedence.Compute(parsedTemplate), + RouteGroup = constraint.RouteValue, + RouteTemplate = action.RouteTemplate, + }); } - return routeTemplatesByGroup; + return routeInfos; + } + + private class RouteInfo + { + public ActionDescriptor ActionDescriptor { get; set; } + + public Template ParsedTemplate { get; set; } + + public decimal Precedence { get; set; } + + public string RouteGroup { get; set; } + + public string RouteTemplate { get; set; } } } -} \ No newline at end of file +} diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj index 6a1ead45a6..1152907613 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj @@ -76,6 +76,7 @@ + diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTests.cs new file mode 100644 index 0000000000..612f28327b --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Routing/AttributeRouteTests.cs @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Routing; +using Microsoft.AspNet.Routing.Template; +using Microsoft.Framework.OptionsModel; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Routing +{ + public class AttributeRouteTests + { + [Fact] + public void AttributeRoute_GenerateLink_NoRequiredValues() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { action = "Index", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_NoMatch() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { action = "Details", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Null(path); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match_WithAmbientValues() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { action = "Index", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { }, new { action = "Index", controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match_WithParameters() + { + // Arrange + var entry = CreateGenerationEntry("api/Store/{action}", new { action = "Index", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store/Index", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match_WithMoreParameters() + { + // Arrange + var entry = CreateGenerationEntry( + "api/{area}/dosomething/{controller}/{action}", + new { action = "Index", controller = "Store", area = "AwesomeCo" }); + + var expectedValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "area", "AwesomeCo" }, + { "controller", "Store" }, + { "action", "Index" }, + { AttributeRouting.RouteGroupKey, entry.RouteGroup }, + }; + + var next = new StubRouter(); + var route = CreateAttributeRoute(next, entry); + + var context = CreateVirtualPathContext( + new { action = "Index", controller = "Store" }, + new { area = "AwesomeCo" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/AwesomeCo/dosomething/Store/Index", path); + Assert.Equal(expectedValues, next.GenerationContext.ProvidedValues); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match_WithDefault() + { + // Arrange + var entry = CreateGenerationEntry("api/Store/{action=Index}", new { action = "Index", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match_WithConstraint() + { + // Arrange + var entry = CreateGenerationEntry("api/Store/{action}/{id:int}", new { action = "Index", controller = "Store" }); + + var expectedValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "action", "Index" }, + { "id", 5 }, + { AttributeRouting.RouteGroupKey, entry.RouteGroup }, + }; + + var next = new StubRouter(); + var route = CreateAttributeRoute(next, entry); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store", id = 5 }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store/Index/5", path); + Assert.Equal(expectedValues, next.GenerationContext.ProvidedValues); + } + + [Fact] + public void AttributeRoute_GenerateLink_NoMatch_WithConstraint() + { + // Arrange + var entry = CreateGenerationEntry("api/Store/{action}/{id:int}", new { action = "Index", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var expectedValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "id", "5" }, + { AttributeRouting.RouteGroupKey, entry.RouteGroup }, + }; + + var next = new StubRouter(); + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store", id = "heyyyy" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Null(path); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match_WithMixedAmbientValues() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { action = "Index", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { action = "Index" }, new { controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_Match_WithQueryString() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { action = "Index", controller = "Store" }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { action = "Index", id = 5}, new { controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api/Store?id=5", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_ForwardsRouteGroup() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { action = "Index", controller = "Store" }); + + var expectedValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { AttributeRouting.RouteGroupKey, entry.RouteGroup }, + }; + + var next = new StubRouter(); + var route = CreateAttributeRoute(next, entry); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal(expectedValues, next.GenerationContext.ProvidedValues); + } + + [Fact] + public void AttributeRoute_GenerateLink_RejectedByFirstRoute() + { + // Arrange + var entry1 = CreateGenerationEntry("api/Store", new { action = "Index", controller = "Store" }); + var entry2 = CreateGenerationEntry("api2/{controller}", new { action = "Index", controller = "Blog" }); + + var route = CreateAttributeRoute(entry1, entry2); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Blog" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api2/Blog", path); + } + + [Fact] + public void AttributeRoute_GenerateLink_RejectedByHandler() + { + // Arrange + var entry1 = CreateGenerationEntry("api/Store", new { action = "Edit", controller = "Store" }); + var entry2 = CreateGenerationEntry("api2/{controller}", new { action = "Edit", controller = "Store" }); + + var next = new StubRouter(); + + var callCount = 0; + next.GenerationDelegate = (VirtualPathContext c) => + { + // Reject entry 1. + callCount++; + return !c.ProvidedValues.Contains(new KeyValuePair( + AttributeRouting.RouteGroupKey, + entry1.RouteGroup)); + }; + + var route = CreateAttributeRoute(next, entry1, entry2); + + var context = CreateVirtualPathContext(new { action = "Edit", controller = "Store" }); + + // Act + var path = route.GetVirtualPath(context); + + // Assert + Assert.Equal("api2/Store", path); + Assert.Equal(2, callCount); + } + + private static VirtualPathContext CreateVirtualPathContext(object values, object ambientValues = null) + { + var httpContext = Mock.Of(); + + return new VirtualPathContext( + httpContext, + new RouteValueDictionary(ambientValues), + new RouteValueDictionary(values)); + } + + private static AttributeRouteGenerationEntry CreateGenerationEntry(string template, object requiredValues) + { + var constraintResolver = CreateConstraintResolver(); + + var entry = new AttributeRouteGenerationEntry(); + entry.Template = TemplateParser.Parse(template, constraintResolver); + + var defaults = entry.Template.Parameters + .Where(p => p.DefaultValue != null) + .ToDictionary(p => p.Name, p => p.DefaultValue); + + var constraints = entry.Template.Parameters + .Where(p => p.InlineConstraint != null) + .ToDictionary(p => p.Name, p => p.InlineConstraint); + + entry.Constraints = constraints; + entry.Defaults = defaults; + entry.Binder = new TemplateBinder(entry.Template, defaults); + entry.Precedence = AttributeRoutePrecedence.Compute(entry.Template); + entry.RequiredLinkValues = new RouteValueDictionary(requiredValues); + entry.RouteGroup = template; + + return entry; + } + + private static DefaultInlineConstraintResolver CreateConstraintResolver() + { + var services = Mock.Of(); + + var options = new RouteOptions(); + var optionsMock = new Mock>(); + optionsMock.SetupGet(o => o.Options).Returns(options); + + return new DefaultInlineConstraintResolver(services, optionsMock.Object); + } + + private static AttributeRoute CreateAttributeRoute(AttributeRouteGenerationEntry entry) + { + return CreateAttributeRoute(new StubRouter(), entry); + } + + private static AttributeRoute CreateAttributeRoute(IRouter next, AttributeRouteGenerationEntry entry) + { + return CreateAttributeRoute(next, new[] { entry }); + } + + private static AttributeRoute CreateAttributeRoute(params AttributeRouteGenerationEntry[] entries) + { + return CreateAttributeRoute(new StubRouter(), entries); + } + + private static AttributeRoute CreateAttributeRoute(IRouter next, params AttributeRouteGenerationEntry[] entries) + { + return new AttributeRoute( + next, + Enumerable.Empty(), + entries); + } + + private class StubRouter : IRouter + { + public VirtualPathContext GenerationContext { get; set; } + + public Func GenerationDelegate { get; set; } + + public RouteContext MatchingContext { get; set; } + + public Func MatchingDelegate { get; set; } + + public string GetVirtualPath(VirtualPathContext context) + { + GenerationContext = context; + + if (GenerationDelegate == null) + { + context.IsBound = true; + } + else + { + context.IsBound = GenerationDelegate(context); + } + + return null; + } + + public Task RouteAsync(RouteContext context) + { + if (MatchingDelegate == null) + { + context.IsHandled = true; + } + else + { + context.IsHandled = MatchingDelegate(context); + } + + return Task.FromResult(null); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs index 1876cc64c0..2bbe1e20a3 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/RoutingTests.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json; using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Routing; using Microsoft.AspNet.TestHost; using Xunit; + namespace Microsoft.AspNet.Mvc.FunctionalTests { public class RoutingTests @@ -153,6 +156,14 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests 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); } // The url would be /Store/ListProducts with conventional routes @@ -191,15 +202,6 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Contains("/Home/About", result.ExpectedUrls); Assert.Equal("Store", result.Controller); Assert.Equal("About", result.Action); - - // A convention-routed action would have values for action and controller. - Assert.None( - result.RouteValues, - (kvp) => string.Equals(kvp.Key, "action", StringComparison.OrdinalIgnoreCase)); - - Assert.None( - result.RouteValues, - (kvp) => string.Equals(kvp.Key, "controller", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -222,7 +224,10 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal("Blog", result.Controller); Assert.Equal("Edit", result.Action); - // This route is parameterized on {action}, but not controller. + Assert.Contains( + new KeyValuePair("controller", "Blog"), + result.RouteValues); + Assert.Contains( new KeyValuePair("action", "Edit"), result.RouteValues); @@ -230,10 +235,6 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Contains( new KeyValuePair("postId", "5"), result.RouteValues); - - Assert.None( - result.RouteValues, - (kvp) => string.Equals(kvp.Key, "controller", StringComparison.OrdinalIgnoreCase)); } // There's no [HttpGet] on the action here. @@ -310,6 +311,392 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests result.RouteValues); } + [Fact] + public async Task AttributeRoutedAction_LinkToSelf() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/api/Employee").To(new { }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/api/Employee", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_LinkWithAmbientController() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Get", id = 5 }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/api/Employee/5", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_LinkToAttribueRoutedController() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "ShowPosts", controller = "Blog" }); + var response = await client.GetAsync(url); + + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/Blog", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_LinkToConventionalController() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/api/Employee").To(new { action = "Index", controller = "Home" }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_LinkToArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/") + .To(new { action = "BuyTickets", controller = "Flight", area = "Travel" }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Home", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight/BuyTickets", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_InArea_ImplicitLinkToArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "BuyTickets" }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight/BuyTickets", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_InArea_ExplicitLeaveArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "Index", controller = "Home", area = "" }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_InArea_ImplicitLeaveArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/Travel/Flight").To(new { action = "Contact", controller = "Home", }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Home/Contact", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_LinkToArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/api/Employee") + .To(new { action = "Schedule", controller = "Rail", area = "Travel" }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Employee", result.Controller); + Assert.Equal("List", result.Action); + + Assert.Equal("/ContosoCorp/Trains/CheckSchedule", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_InArea_ImplicitLinkToArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule").To(new { action = "Index" }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Rail", result.Controller); + Assert.Equal("Schedule", result.Action); + + Assert.Equal("/ContosoCorp/Trains", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_InArea_ExplicitLeaveArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/ContosoCorp/Trains/CheckSchedule") + .To(new { action = "Index", controller = "Home", area = "" }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Rail", result.Controller); + Assert.Equal("Schedule", result.Action); + + Assert.Equal("/", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_InArea_ImplicitLeaveArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "Contact", controller = "Home", }); + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Home/Contact", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_InArea_LinkToConventionalRoutedActionInArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "Index", controller = "Flight", }); + + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Travel/Flight", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_InArea_LinkToAttributeRoutedActionInArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "Index", controller = "Rail", }); + + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/ContosoCorp/Trains", result.Link); + } + + [Fact] + public async Task ConventionalRoutedAction_InArea_LinkToAnotherArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/Travel/Flight") + .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); + + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Flight", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Admin/Users/All", result.Link); + } + + [Fact] + public async Task AttributeRoutedAction_InArea_LinkToAnotherArea() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.Handler; + + // Act + var url = LinkFrom("http://localhost/ContosoCorp/Trains") + .To(new { action = "ListUsers", controller = "UserManagement", area = "Admin" }); + + var response = await client.GetAsync(url); + Assert.Equal(200, response.StatusCode); + + // Assert + var body = await response.ReadBodyAsStringAsync(); + var result = JsonConvert.DeserializeObject(body); + + // Assert + Assert.Equal("Rail", result.Controller); + Assert.Equal("Index", result.Action); + + Assert.Equal("/Admin/Users/All", result.Link); + } + + private static LinkBuilder LinkFrom(string url) + { + return new LinkBuilder(url); + } + // See TestResponseGenerator in RoutingWebSite for the code that generates this data. private class RoutingResult { @@ -322,6 +709,44 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests 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(); + } } } -} \ No newline at end of file +} diff --git a/test/WebSites/RoutingWebSite/Areas/Admin/UserManagementController.cs b/test/WebSites/RoutingWebSite/Areas/Admin/UserManagementController.cs new file mode 100644 index 0000000000..b1feae4d56 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Areas/Admin/UserManagementController.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace RoutingWebSite.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/RoutingWebSite/Areas/Travel/FlightController.cs b/test/WebSites/RoutingWebSite/Areas/Travel/FlightController.cs index ae5730e9a0..e47d4c0368 100644 --- a/test/WebSites/RoutingWebSite/Areas/Travel/FlightController.cs +++ b/test/WebSites/RoutingWebSite/Areas/Travel/FlightController.cs @@ -1,5 +1,7 @@ -using Microsoft.AspNet.Mvc; -using System; +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; namespace RoutingWebSite { diff --git a/test/WebSites/RoutingWebSite/Areas/Travel/HomeController.cs b/test/WebSites/RoutingWebSite/Areas/Travel/HomeController.cs new file mode 100644 index 0000000000..e84147f741 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Areas/Travel/HomeController.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace RoutingWebSite.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/RoutingWebSite/Areas/Travel/RailController.cs b/test/WebSites/RoutingWebSite/Areas/Travel/RailController.cs new file mode 100644 index 0000000000..e55976d775 --- /dev/null +++ b/test/WebSites/RoutingWebSite/Areas/Travel/RailController.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace RoutingWebSite +{ + [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/RoutingWebSite/Controllers/HomeController.cs b/test/WebSites/RoutingWebSite/Controllers/HomeController.cs index b6957f3294..248d40d677 100644 --- a/test/WebSites/RoutingWebSite/Controllers/HomeController.cs +++ b/test/WebSites/RoutingWebSite/Controllers/HomeController.cs @@ -25,5 +25,10 @@ namespace RoutingWebSite // 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"); + } } } \ No newline at end of file diff --git a/test/WebSites/RoutingWebSite/RoutingWebSite.kproj b/test/WebSites/RoutingWebSite/RoutingWebSite.kproj index 79c4fb17ed..8557aedcb0 100644 --- a/test/WebSites/RoutingWebSite/RoutingWebSite.kproj +++ b/test/WebSites/RoutingWebSite/RoutingWebSite.kproj @@ -28,7 +28,10 @@ 11178 + + + diff --git a/test/WebSites/RoutingWebSite/TestResponseGenerator.cs b/test/WebSites/RoutingWebSite/TestResponseGenerator.cs index 53b9a8abcf..0dd134fc94 100644 --- a/test/WebSites/RoutingWebSite/TestResponseGenerator.cs +++ b/test/WebSites/RoutingWebSite/TestResponseGenerator.cs @@ -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.Linq; using Microsoft.AspNet.Mvc; using Microsoft.Framework.DependencyInjection; @@ -23,6 +24,18 @@ namespace RoutingWebSite 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 = _actionContext.HttpContext.RequestServices.GetService(); + link = urlHelper.Action(query["link_action"], query["link_controller"], values); + } + return new JsonResult(new { expectedUrls = expectedUrls, @@ -31,6 +44,8 @@ namespace RoutingWebSite action = _actionContext.ActionDescriptor.Name, controller = ((ReflectedActionDescriptor)_actionContext.ActionDescriptor).ControllerDescriptor.Name, + + link, }); } }