From ec4b3a29c0ca0001dc6d51fef58ef8d99bd902b0 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 31 Mar 2014 12:28:38 -0700 Subject: [PATCH] Adding smart link generation This feature will enforce a contract that link generation has to point to a real action. Read the comments in code for more details and rationale. --- out.txt | 0 .../Travel/Controllers/HomeController.cs | 14 + .../Areas/Travel/Views/Flight/Fly.cshtml | 22 +- .../MvcSample.Web/Views/Shared/MyView.cshtml | 8 +- .../MvcSample.Web/Views/Shared/_Layout.cshtml | 4 +- .../DefaultActionSelector.cs | 239 ++++++++++++++- .../IActionSelector.cs | 9 +- .../MvcApplication.cs | 10 +- .../Properties/Resources.Designer.cs | 20 +- src/Microsoft.AspNet.Mvc.Core/Resources.resx | 5 +- .../RouteDataActionConstraint.cs | 73 +++-- src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs | 39 ++- .../DefaultActionSelectorTest.cs | 280 ++++++++++++++++++ .../UrlHelperTest.cs | 10 +- .../project.json | 7 +- 15 files changed, 672 insertions(+), 68 deletions(-) create mode 100644 out.txt create mode 100644 samples/MvcSample.Web/Areas/Travel/Controllers/HomeController.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTest.cs diff --git a/out.txt b/out.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samples/MvcSample.Web/Areas/Travel/Controllers/HomeController.cs b/samples/MvcSample.Web/Areas/Travel/Controllers/HomeController.cs new file mode 100644 index 0000000000..5dd329b4f6 --- /dev/null +++ b/samples/MvcSample.Web/Areas/Travel/Controllers/HomeController.cs @@ -0,0 +1,14 @@ + +using Microsoft.AspNet.Mvc; + +namespace MvcSample.Web.Areas.Travel.Controllers +{ + [Area("Travel")] + public class HomeController : Controller + { + public IActionResult Index() + { + return Result.Content("This is the Travel/Home/Index action."); + } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Areas/Travel/Views/Flight/Fly.cshtml b/samples/MvcSample.Web/Areas/Travel/Views/Flight/Fly.cshtml index ed15ba423b..14a1ef94ce 100644 --- a/samples/MvcSample.Web/Areas/Travel/Views/Flight/Fly.cshtml +++ b/samples/MvcSample.Web/Areas/Travel/Views/Flight/Fly.cshtml @@ -12,22 +12,18 @@
-

Getting started

-

- ASP.NET MVC gives you a powerful, patterns-based way to build dynamic websites that - enables a clean separation of concerns and gives you full control over markup - for enjoyable, agile development. -

-

Learn more »

+

Back to the main Area.

+

Takes you out of the area implicitly.

+

Go to Home/Edit

-

Get more libraries

-

NuGet is a free Visual Studio extension that makes it easy to add, remove, and update libraries and tools in Visual Studio projects.

-

Learn more »

+

Go to another action in the Area.

+

Keeps you in the area implicitly.

+

Go to Travel/Home/Index

-

Web Hosting

-

You can easily find a web hosting company that offers the right mix of features and price for your applications.

-

Learn more »

+

Back to the main Area (explicit).

+

Leaves the area explicitly.

+

Go to Home/Index

diff --git a/samples/MvcSample.Web/Views/Shared/MyView.cshtml b/samples/MvcSample.Web/Views/Shared/MyView.cshtml index de853dd191..9964d7cdce 100644 --- a/samples/MvcSample.Web/Views/Shared/MyView.cshtml +++ b/samples/MvcSample.Web/Views/Shared/MyView.cshtml @@ -64,13 +64,11 @@ }
-

Getting started

+

Book a flight

- ASP.NET MVC gives you a powerful, patterns-based way to build dynamic websites that - enables a clean separation of concerns and gives you full control over markup - for enjoyable, agile development. + Go to our cool travel reservation system.

-

Learn more »

+

Reserve Now

Get more libraries

diff --git a/samples/MvcSample.Web/Views/Shared/_Layout.cshtml b/samples/MvcSample.Web/Views/Shared/_Layout.cshtml index 2b22ef3017..4879428656 100644 --- a/samples/MvcSample.Web/Views/Shared/_Layout.cshtml +++ b/samples/MvcSample.Web/Views/Shared/_Layout.cshtml @@ -5,7 +5,7 @@ @ViewBag.Title - My ASP.NET Application - @RenderSection("header") + @RenderSection("header", required: false) - @RenderSection("footer") + @RenderSection("footer", required: false) diff --git a/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs b/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs index c967cdb840..a064c29e49 100644 --- a/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs +++ b/src/Microsoft.AspNet.Mvc.Core/DefaultActionSelector.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics.Contracts; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNet.DependencyInjection; +using Microsoft.AspNet.Mvc.Core; +using Microsoft.AspNet.Routing; namespace Microsoft.AspNet.Mvc { @@ -11,7 +14,7 @@ namespace Microsoft.AspNet.Mvc private readonly INestedProviderManager _actionDescriptorProvider; private readonly IActionBindingContextProvider _bindingProvider; - public DefaultActionSelector(INestedProviderManager actionDescriptorProvider, + public DefaultActionSelector(INestedProviderManager actionDescriptorProvider, IActionBindingContextProvider bindingProvider) { _actionDescriptorProvider = actionDescriptorProvider; @@ -25,10 +28,7 @@ namespace Microsoft.AspNet.Mvc throw new ArgumentNullException("context"); } - var actionDescriptorProviderContext = new ActionDescriptorProviderContext(); - _actionDescriptorProvider.Invoke(actionDescriptorProviderContext); - - var allDescriptors = actionDescriptorProviderContext.Results; + var allDescriptors = GetActions(); var matching = allDescriptors.Where(ad => Match(ad, context)).ToList(); if (matching.Count == 0) @@ -70,9 +70,9 @@ namespace Microsoft.AspNet.Mvc // Issues #60 & #65 filed to deal with the ugliness of passing null here. var actionContext = new ActionContext( - httpContext: context.HttpContext, - router: null, - routeValues: context.RouteValues, + httpContext: context.HttpContext, + router: null, + routeValues: context.RouteValues, actionDescriptor: action); var actionBindingContext = await _bindingProvider.GetActionBindingContextAsync(actionContext); @@ -114,6 +114,210 @@ namespace Microsoft.AspNet.Mvc return fewestOptionalParameters[0].Action; } + // This method attempts to ensure that the route that's about to generate a link will generate a link + // to an existing action. This method is called by a route (through MvcApplication) prior to generating + // any link - this gives WebFX a chance to 'veto' the values provided by a route. + // + // This method does not take httpmethod or dynamic action constraints into account. + public virtual bool HasValidAction([NotNull] VirtualPathContext context) + { + if (context.ProvidedValues == null) + { + // We need the route's values to be able to double check our work. + return false; + } + + var actions = + GetActions().Where( + action => + action.RouteConstraints == null || + action.RouteConstraints.All(constraint => constraint.Accept(context.ProvidedValues))); + + return actions.Any(); + } + + // This is called by the default UrlHelper as part of Action link generation. When a link is requested + // specifically for an Action, we manipulate the route data to ensure that the right link is generated. + // Read further for details. + public virtual IEnumerable GetCandidateActions(VirtualPathContext context) + { + // This method attemptss to find a unique 'best' candidate set of actions from the provided route + // values and ambient route values. + // + // The purpose of this process is to avoid allowing certain routes to be too greedy. When a route uses + // a default value as a filter, it can generate links to actions it will never hit. The actions returned + // by this method are used by the link generation code to manipulate the route values so that routes that + // are are greedy can't generate a link. + // + // The best example of this greediness is the canonical 'area' route from MVC. + // + // Ex: Areas/Admin/{controller}/{action} (defaults { area = "Admin" }) + // + // This route can generate a link even when the 'area' token is not provided. + // + // + // We define 'best' based on the combination of Values and AmbientValues. This set can be used to select a + // set of actions, anything in this is set is 'reachable'. We determine 'best' by looking for the 'reachable' + // actions ordered by the most total constraints matched, then the most constraints matched by ambient values. + // + // Ex: + // Consider the following actions - Home/Index (no area), and Admin/Home/Index (area = Admin). + // ambient values = { area = "Admin", controller = "Home", action = "Diagnostics" } + // values = { action = "Index" } + // + // In this case we want to select the Admin/Home/Index action, and algorithm leads us there. + // + // Admin/Home/Index: Total score 3, Explicit score 2, Implicit score 1, Omission score 0 + // Home/Index: Total score 3, Explicit score 2, Implicit score 0, Omission score 1 + // + // The description here is based on the concepts we're using to implement areas in WebFx, but apply + // to any tokens that might be used in routing (including REST conventions when action == null). + // + // This method does not take httpmethod or dynamic action constraints into account. + + var actions = GetActions(); + + var candidates = new List(); + foreach (var action in actions) + { + var candidate = new ActionDescriptorLinkCandidate() { Action = action }; + if (action.RouteConstraints == null) + { + candidates.Add(candidate); + continue; + } + + bool isActionValid = true; + foreach (var constraint in action.RouteConstraints) + { + if (constraint.Accept(context.Values)) + { + if (context.Values.ContainsKey(constraint.RouteKey)) + { + // Explicit value is acceptable + candidate.ExplicitMatches++; + } + else + { + // No value supplied and that's OK for this action. + candidate.OmissionMatches++; + } + } + else if (context.Values.ContainsKey(constraint.RouteKey)) + { + // There's an explicitly provided value, but the action constraint doesn't match it. + isActionValid = false; + break; + } + else if (constraint.Accept(context.AmbientValues)) + { + // Ambient value is acceptable, used as a fallback + candidate.ImplicitMatches++; + } + else + { + // No possible match + isActionValid = false; + break; + } + } + + if (isActionValid) + { + candidates.Add(candidate); + } + } + + if (candidates.Count == 0) + { + return Enumerable.Empty(); + } + + // Finds all of the actions with the maximum number of total constraint matches. + var longestMatches = + candidates + .GroupBy(c => c.TotalMatches) + .OrderByDescending(g => g.Key) + .First(); + + // Finds all of the actions (from the above set) with the maximum number of explicit constraint matches. + var bestMatchesByExplicit = + longestMatches + .GroupBy(c => c.ExplicitMatches) + .OrderByDescending(g => g.Key) + .First(); + + // Finds all of the actions (from the above set) with the maximum number of implicit constraint matches. + var bestMatchesByImplicit = + bestMatchesByExplicit + .GroupBy(c => c.ImplicitMatches) + .OrderByDescending(g => g.Key) + .First(); + + var bestActions = bestMatchesByImplicit.Select(m => m.Action).ToArray(); + if (bestActions.Length == 1) + { + return bestActions; + } + + var exemplar = FindEquivalenceClass(bestActions); + if (exemplar == null) + { + throw new InvalidOperationException(Resources.ActionSelector_GetCandidateActionsIsAmbiguous); + } + else + { + return bestActions; + } + } + + // This method determines if the set of action descriptor candidates share a common set + // of route constraints, and returns an exemplar if there's a single set. This identifies + // a type of ambiguity, more data must be specified to ensure the right action can be selected. + // + // This is a no-op for our default conventions, but becomes important with custom action + // descriptor providers. + // + // Ex: These are not in the same equivalence class. + // Action 1: constraint keys - { action, controller, area } + // Action 2: constraint keys - { action, module } + // + private ActionDescriptor FindEquivalenceClass(ActionDescriptor[] candidates) + { + Contract.Assert(candidates.Length > 1); + + var criteria = new HashSet(StringComparer.OrdinalIgnoreCase); + + var exemplar = candidates[0]; + foreach (var constraint in exemplar.RouteConstraints) + { + criteria.Add(constraint.RouteKey); + } + + for (var i = 1; i < candidates.Length; i++) + { + var candidate = candidates[i]; + foreach (var constraint in exemplar.RouteConstraints) + { + if (criteria.Add(constraint.RouteKey)) + { + // This is a new criterion - the candidates have multiple criteria sets + return null; + } + } + } + + return exemplar; + } + + private List GetActions() + { + var actionDescriptorProviderContext = new ActionDescriptorProviderContext(); + _actionDescriptorProvider.Invoke(actionDescriptorProviderContext); + + return actionDescriptorProviderContext.Results; + } + private class ActionDescriptorCandidate { public ActionDescriptor Action { get; set; } @@ -122,5 +326,24 @@ namespace Microsoft.AspNet.Mvc public int FoundOptionalParameters { get; set; } } + + private class ActionDescriptorLinkCandidate + { + public ActionDescriptor Action { get; set; } + + // Matches from explicit route values + public int ExplicitMatches { get; set; } + + // Matches from ambient route values + public int ImplicitMatches { get; set; } + + // Matches from explicit route values (by omission) + public int OmissionMatches { get; set; } + + public int TotalMatches + { + get { return ExplicitMatches + ImplicitMatches + OmissionMatches; } + } + } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/IActionSelector.cs b/src/Microsoft.AspNet.Mvc.Core/IActionSelector.cs index 6b1e07ded9..e7fcd3dd90 100644 --- a/src/Microsoft.AspNet.Mvc.Core/IActionSelector.cs +++ b/src/Microsoft.AspNet.Mvc.Core/IActionSelector.cs @@ -1,4 +1,7 @@ -using System.Threading.Tasks; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNet.Routing; namespace Microsoft.AspNet.Mvc { @@ -7,5 +10,9 @@ namespace Microsoft.AspNet.Mvc Task SelectAsync(RequestContext context); bool Match(ActionDescriptor descriptor, RequestContext context); + + bool HasValidAction(VirtualPathContext context); + + IEnumerable GetCandidateActions(VirtualPathContext context); } } diff --git a/src/Microsoft.AspNet.Mvc.Core/MvcApplication.cs b/src/Microsoft.AspNet.Mvc.Core/MvcApplication.cs index 36040fca96..fdd78367ca 100644 --- a/src/Microsoft.AspNet.Mvc.Core/MvcApplication.cs +++ b/src/Microsoft.AspNet.Mvc.Core/MvcApplication.cs @@ -1,6 +1,8 @@  using System; +using System.Collections.Generic; using System.Diagnostics.Contracts; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNet.Abstractions; using Microsoft.AspNet.DependencyInjection; @@ -20,8 +22,12 @@ namespace Microsoft.AspNet.Mvc public string GetVirtualPath([NotNull] VirtualPathContext context) { - // For now just allow any values to target this application. - context.IsBound = true; + // The contract of this method is to check that the values coming in from the route are valid; + // that they match an existing action, setting IsBound = true if the values are OK. + var actionSelector = context.Context.RequestServices.GetService(); + context.IsBound = actionSelector.HasValidAction(context); + + // We return null here because we're not responsible for generating the url, the route is. return null; } diff --git a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs index 862d4280d6..949dc94e52 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs @@ -267,7 +267,7 @@ namespace Microsoft.AspNet.Mvc.Core } /// - /// The '{0}' must return a non null '{1}'. + /// The '{0}' must return a non-null '{1}'. /// internal static string MethodMustReturnNotNullValue { @@ -275,13 +275,29 @@ namespace Microsoft.AspNet.Mvc.Core } /// - /// The '{0}' must return a non null '{1}'. + /// The '{0}' must return a non-null '{1}'. /// internal static string FormatMethodMustReturnNotNullValue(object p0, object p1) { return string.Format(CultureInfo.CurrentCulture, GetString("MethodMustReturnNotNullValue"), p0, p1); } + /// + /// The supplied route values are ambiguous and can select multiple sets of actions. + /// + internal static string ActionSelector_GetCandidateActionsIsAmbiguous + { + get { return GetString("ActionSelector_GetCandidateActionsIsAmbiguous"); } + } + + /// + /// The supplied route values are ambiguous and can select multiple sets of actions. + /// + internal static string FormatActionSelector_GetCandidateActionsIsAmbiguous() + { + return GetString("ActionSelector_GetCandidateActionsIsAmbiguous"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Mvc.Core/Resources.resx b/src/Microsoft.AspNet.Mvc.Core/Resources.resx index 7698b726e2..4f1edbe75c 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Core/Resources.resx @@ -168,4 +168,7 @@ The '{0}' must return a non-null '{1}'. - + + The supplied route values are ambiguous and can select multiple sets of actions. + + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs b/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs index 1a72bc9fe4..6e12120cff 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RouteDataActionConstraint.cs @@ -1,7 +1,9 @@ using System; using System.Collections; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using Microsoft.AspNet.Mvc.Core; namespace Microsoft.AspNet.Mvc { @@ -70,41 +72,60 @@ namespace Microsoft.AspNet.Mvc } } - public bool Accept(RequestContext context) + public bool Accept([NotNull] IDictionary routeValues) { - if (context == null) - { - throw new ArgumentNullException("context"); - } - - var routeValues = context.RouteValues; - - if (routeValues == null) - { - throw new ArgumentException("Need route values", "context"); - } - + object value; switch (KeyHandling) { case RouteKeyHandling.AcceptAlways: return true; - case RouteKeyHandling.DenyKey: - return !routeValues.ContainsKey(RouteKey); + case RouteKeyHandling.CatchAll: return routeValues.ContainsKey(RouteKey); + + case RouteKeyHandling.DenyKey: + // Routing considers a null or empty string to also be the lack of a value + if (!routeValues.TryGetValue(RouteKey, out value) || value == null) + { + return true; + } + + var stringValue = value as string; + if (stringValue != null && stringValue.Length == 0) + { + return true; + } + + return false; + + case RouteKeyHandling.RequireKey: + if (routeValues.TryGetValue(RouteKey, out value)) + { + return Comparer.Equals(value, RouteValue); + } + else + { + return false; + } + + default: + Debug.Fail("Unexpected routeValue"); + return false; + } + } + + public bool Accept([NotNull] RequestContext context) + { + var routeValues = context.RouteValues; + if (routeValues == null) + { + throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull( + "RouteValues", + typeof(RequestContext)), + "context"); } - Debug.Assert(KeyHandling == RouteKeyHandling.RequireKey, "Unexpected routeValue"); - - object value; - if (routeValues.TryGetValue(RouteKey, out value)) - { - return Comparer.Equals(value, RouteValue); - } - else - { - return false; - } + return Accept(routeValues); } } } diff --git a/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs b/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs index 4ab97acf21..6d22a1ed32 100644 --- a/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs +++ b/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Globalization; +using System.Globalization; +using System.Linq; using Microsoft.AspNet.Abstractions; using Microsoft.AspNet.DependencyInjection; using Microsoft.AspNet.Mvc.Rendering; @@ -14,12 +16,14 @@ namespace Microsoft.AspNet.Mvc private readonly HttpContext _httpContext; private readonly IRouter _router; private readonly IDictionary _ambientValues; + private readonly IActionSelector _actionSelector; - public UrlHelper(IContextAccessor contextAccessor) + public UrlHelper(IContextAccessor contextAccessor, IActionSelector actionSelector) { _httpContext = contextAccessor.Value.HttpContext; _router = contextAccessor.Value.Router; _ambientValues = contextAccessor.Value.RouteValues; + _actionSelector = actionSelector; } public string Action(string action, string controller, object values, string protocol, string host, string fragment) @@ -44,6 +48,24 @@ namespace Microsoft.AspNet.Mvc valuesDictionary["controller"] = controller; } + var context = new VirtualPathContext(_httpContext, _ambientValues, valuesDictionary); + var actions = _actionSelector.GetCandidateActions(context); + + var actionCandidate = actions.FirstOrDefault(); + if (actionCandidate == null) + { + return null; + } + + foreach (var constraint in actionCandidate.RouteConstraints) + { + if (constraint.KeyHandling == RouteKeyHandling.DenyKey && + _ambientValues.ContainsKey(constraint.RouteKey)) + { + valuesDictionary[constraint.RouteKey] = null; + } + } + var path = GeneratePathFromRoute(valuesDictionary); if (path == null) { @@ -67,8 +89,11 @@ namespace Microsoft.AspNet.Mvc private string GeneratePathFromRoute(IDictionary values) { var context = new VirtualPathContext(_httpContext, _ambientValues, values); - var path = _router.GetVirtualPath(context); + if (path == null) + { + return null; + } // See Routing Issue#31 if (path.Length > 0 && path[0] != '/') @@ -76,7 +101,15 @@ namespace Microsoft.AspNet.Mvc path = "/" + path; } - return _httpContext.Request.PathBase.Add(new PathString(path)).Value; + var fullPath = _httpContext.Request.PathBase.Add(new PathString(path)).Value; + if (fullPath.Length == 0) + { + return "/"; + } + else + { + return fullPath; + } } public string Content([NotNull] string contentPath) diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTest.cs new file mode 100644 index 0000000000..06c84cc120 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/DefaultActionSelectorTest.cs @@ -0,0 +1,280 @@ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Abstractions; +using Microsoft.AspNet.DependencyInjection; +using Microsoft.AspNet.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Core.Test +{ + public class DefaultActionSelectorTest + { + [Fact] + public void GetCandidateActions_Match_NonArea() + { + // Arrange + var actions = GetActions(); + var expected = GetActions(actions, area: null, controller: "Home", action: "Index"); + + var selector = CreateSelector(actions); + var context = CreateContext(new { controller = "Home", action = "Index" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Equal(expected, candidates); + } + + [Fact] + public void GetCandidateActions_Match_AreaExplicit() + { + // Arrange + var actions = GetActions(); + var expected = GetActions(actions, area: "Admin", controller: "Home", action: "Index"); + + var selector = CreateSelector(actions); + var context = CreateContext(new { area = "Admin", controller = "Home", action = "Index" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Equal(expected, candidates); + } + + [Fact] + public void GetCandidateActions_Match_AreaImplicit() + { + // Arrange + var actions = GetActions(); + var expected = GetActions(actions, area: "Admin", controller: "Home", action: "Index"); + + var selector = CreateSelector(actions); + var context = CreateContext( + new { controller = "Home", action = "Index" }, + new { area = "Admin", controller = "Home", action = "Diagnostics" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Equal(expected, candidates); + } + + [Fact] + public void GetCandidateActions_Match_NonAreaImplicit() + { + // Arrange + var actions = GetActions(); + var expected = GetActions(actions, area: null, controller: "Home", action: "Edit"); + + var selector = CreateSelector(actions); + var context = CreateContext( + new { controller = "Home", action = "Edit" }, + new { area = "Admin", controller = "Home", action = "Diagnostics" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Equal(expected, candidates); + } + + [Fact] + public void GetCandidateActions_Match_NonAreaExplicit() + { + // Arrange + var actions = GetActions(); + var expected = GetActions(actions, area: null, controller: "Home", action: "Index"); + + var selector = CreateSelector(actions); + var context = CreateContext( + new { area = (string)null, controller = "Home", action = "Index" }, + new { area = "Admin", controller = "Home", action = "Diagnostics" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Equal(expected, candidates); + } + + [Fact] + public void GetCandidateActions_Match_RestExplicit() + { + // Arrange + var actions = GetActions(); + var expected = GetActions(actions, area: null, controller: "Product", action: null); + + var selector = CreateSelector(actions); + var context = CreateContext( + new { controller = "Product", action = (string)null }, + new { controller = "Home", action = "Index" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Equal(expected, candidates); + } + + [Fact] + public void GetCandidateActions_Match_RestImplicit() + { + // Arrange + var actions = GetActions(); + var expected = GetActions(actions, area: null, controller: "Product", action: null); + + var selector = CreateSelector(actions); + var context = CreateContext( + new { controller = "Product" }, + new { controller = "Home", action = "Index" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Equal(expected, candidates); + } + + + [Fact] + public void GetCandidateActions_NoMatch() + { + // Arrange + var actions = GetActions(); + + var selector = CreateSelector(actions); + var context = CreateContext( + new { area = "Admin", controller = "Home", action = "Edit" }, + new { area = "Admin", controller = "Home", action = "Index" }); + + // Act + var candidates = selector.GetCandidateActions(context); + + // Assert + Assert.Empty(candidates); + } + + [Fact] + public void HasValidAction_Match() + { + // Arrange + var actions = GetActions(); + + var selector = CreateSelector(actions); + var context = CreateContext(new { }); + context.ProvidedValues = new RouteValueDictionary(new { controller = "Home", action = "Index"}); + + // Act + var isValid = selector.HasValidAction(context); + + // Assert + Assert.True(isValid); + } + + [Fact] + public void HasValidAction_NoMatch() + { + // Arrange + var actions = GetActions(); + + var selector = CreateSelector(actions); + var context = CreateContext(new { }); + context.ProvidedValues = new RouteValueDictionary(new { controller = "Home", action = "FakeAction" }); + + // Act + var isValid = selector.HasValidAction(context); + + // Assert + Assert.False(isValid); + } + + private static ActionDescriptor[] GetActions() + { + return new ActionDescriptor[] + { + // Like a typical RPC controller + CreateAction(area: null, controller: "Home", action: "Index"), + CreateAction(area: null, controller: "Home", action: "Edit"), + + // Like a typical REST controller + CreateAction(area: null, controller: "Product", action: null), + CreateAction(area: null, controller: "Product", action: null), + + // RPC controller in an area with the same name as home + CreateAction(area: "Admin", controller: "Home", action: "Index"), + CreateAction(area: "Admin", controller: "Home", action: "Diagnostics"), + }; + } + + private static IEnumerable GetActions( + IEnumerable actions, + string area, + string controller, + string action) + { + return + actions + .Where(a => a.RouteConstraints.Any(c => c.RouteKey == "area" && c.Comparer.Equals(c.RouteValue, area))) + .Where(a => a.RouteConstraints.Any(c => c.RouteKey == "controller" && c.Comparer.Equals(c.RouteValue, controller))) + .Where(a => a.RouteConstraints.Any(c => c.RouteKey == "action" && c.Comparer.Equals(c.RouteValue, action))); + } + + private static DefaultActionSelector CreateSelector(IEnumerable actions) + { + var actionProvider = new Mock>(MockBehavior.Strict); + actionProvider + .Setup(p => p.Invoke(It.IsAny())) + .Callback(c => c.Results.AddRange(actions)); + + var bindingProvider = new Mock(MockBehavior.Strict); + + return new DefaultActionSelector(actionProvider.Object, bindingProvider.Object); + } + + private static VirtualPathContext CreateContext(object routeValues) + { + return CreateContext(routeValues, ambientValues: null); + } + + private static VirtualPathContext CreateContext(object routeValues, object ambientValues) + { + var httpContext = new Mock(MockBehavior.Strict); + + return new VirtualPathContext( + httpContext.Object, + new RouteValueDictionary(ambientValues), + new RouteValueDictionary(routeValues)); + } + + private static ActionDescriptor CreateAction(string area, string controller, string action) + { + var actionDescriptor = new ActionDescriptor() + { + Name = string.Format("Area: {0}, Controller: {1}, Action: {2}", area, controller, action), + RouteConstraints = new List(), + }; + + actionDescriptor.RouteConstraints.Add( + area == null ? + new RouteDataActionConstraint("area", RouteKeyHandling.DenyKey) : + new RouteDataActionConstraint("area", area)); + + actionDescriptor.RouteConstraints.Add( + controller == null ? + new RouteDataActionConstraint("controller", RouteKeyHandling.DenyKey) : + new RouteDataActionConstraint("controller", controller)); + + actionDescriptor.RouteConstraints.Add( + action == null ? + new RouteDataActionConstraint("action", RouteKeyHandling.DenyKey) : + new RouteDataActionConstraint("action", action)); + + return actionDescriptor; + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/UrlHelperTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/UrlHelperTest.cs index 2d5f89cbc2..66d666ee26 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/UrlHelperTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/UrlHelperTest.cs @@ -19,7 +19,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test // Arrange var context = CreateHttpContext(appRoot); var contextAccessor = CreateActionContext(context); - var urlHelper = new UrlHelper(contextAccessor); + var urlHelper = CreateUrlHelper(contextAccessor); // Act var path = urlHelper.Content(contentPath); @@ -43,7 +43,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test // Arrange var context = CreateHttpContext(appRoot); var contextAccessor = CreateActionContext(context); - var urlHelper = new UrlHelper(contextAccessor); + var urlHelper = CreateUrlHelper(contextAccessor); // Act var path = urlHelper.Content(contentPath); @@ -75,5 +75,11 @@ namespace Microsoft.AspNet.Mvc.Core.Test .Returns(actionContext); return contextAccessor.Object; } + + private static UrlHelper CreateUrlHelper(IContextAccessor contextAccessor) + { + var actionSelector = new Mock(MockBehavior.Strict); + return new UrlHelper(contextAccessor, actionSelector.Object); + } } } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/project.json b/test/Microsoft.AspNet.Mvc.Core.Test/project.json index 7d3a72d9dd..554eee51f4 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/project.json +++ b/test/Microsoft.AspNet.Mvc.Core.Test/project.json @@ -1,10 +1,11 @@ { "version" : "0.1-alpha-*", "dependencies": { - "Microsoft.AspNet.Abstractions": "0.1-alpha-*", - "Microsoft.AspNet.DependencyInjection": "0.1-alpha-*", + "Microsoft.AspNet.Abstractions": "0.1-alpha-*", + "Microsoft.AspNet.DependencyInjection": "0.1-alpha-*", "Microsoft.AspNet.Mvc.Core" : "", "Microsoft.AspNet.Mvc" : "", + "Microsoft.AspNet.Routing": "0.1-alpha-*", "Microsoft.AspNet.Testing": "0.1-alpha-*", "Microsoft.AspNet.Mvc.ModelBinding": "", "Microsoft.AspNet.Mvc.Rendering": "", @@ -25,4 +26,4 @@ "configurations": { "net45": { } } -} \ No newline at end of file +}