diff --git a/NuGetPackageVerifier.json b/NuGetPackageVerifier.json index 5bf087bc..7861d309 100644 --- a/NuGetPackageVerifier.json +++ b/NuGetPackageVerifier.json @@ -12,6 +12,12 @@ "Microsoft.AspNet.Routing": { } } }, + "adx-nonshipping": { + "rules": [], + "packages": { + "Microsoft.AspNet.Routing.DecisionTree.Sources": { } + } + }, "Default": { // Rules to run for packages not listed in any other set. "rules": [ "AssemblyHasDocumentFileRule", diff --git a/Routing.sln b/Routing.sln index 516acd80..8d7bc70b 100644 --- a/Routing.sln +++ b/Routing.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 -VisualStudioVersion = 14.0.22115.0 +VisualStudioVersion = 14.0.24711.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0E966C37-7334-4D96-AAF6-9F49FBD166E3}" EndProject @@ -20,6 +20,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution global.json = global.json EndProjectSection EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Routing.DecisionTree.Sources", "src\Microsoft.AspNet.Routing.DecisionTree.Sources\Microsoft.AspNet.Routing.DecisionTree.Sources.xproj", "{ABD5AA59-6000-4A3D-A54F-4B636F725AE8}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Routing.DecisionTree.Sources.Tests", "test\Microsoft.AspNet.Routing.DecisionTree.Sources.Tests\Microsoft.AspNet.Routing.DecisionTree.Sources.Tests.xproj", "{09C2933C-23AC-41B7-994D-E8A5184A629C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,6 +64,30 @@ Global {DB94E647-C73A-4F52-A126-AA7544CCF33B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {DB94E647-C73A-4F52-A126-AA7544CCF33B}.Release|Mixed Platforms.Build.0 = Release|Any CPU {DB94E647-C73A-4F52-A126-AA7544CCF33B}.Release|x86.ActiveCfg = Release|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Debug|x86.ActiveCfg = Debug|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Debug|x86.Build.0 = Debug|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Release|Any CPU.Build.0 = Release|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Release|x86.ActiveCfg = Release|Any CPU + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8}.Release|x86.Build.0 = Release|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Debug|x86.ActiveCfg = Debug|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Debug|x86.Build.0 = Debug|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Release|Any CPU.Build.0 = Release|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Release|x86.ActiveCfg = Release|Any CPU + {09C2933C-23AC-41B7-994D-E8A5184A629C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -68,5 +96,7 @@ Global {1EE54D32-6CED-4206-ACF5-3DC1DD39D228} = {0E966C37-7334-4D96-AAF6-9F49FBD166E3} {636D79ED-7B32-487C-BDA5-D2A1AAA97371} = {95359B4B-4C85-4B44-A75B-0621905C4CF6} {DB94E647-C73A-4F52-A126-AA7544CCF33B} = {C3ADD55B-B9C7-4061-8AD4-6A70D1AE3B2E} + {ABD5AA59-6000-4A3D-A54F-4B636F725AE8} = {0E966C37-7334-4D96-AAF6-9F49FBD166E3} + {09C2933C-23AC-41B7-994D-E8A5184A629C} = {95359B4B-4C85-4B44-A75B-0621905C4CF6} EndGlobalSection EndGlobal diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterion.cs b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterion.cs new file mode 100644 index 00000000..588f3f39 --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterion.cs @@ -0,0 +1,16 @@ +// 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.AspNet.Routing.DecisionTree +{ + internal class DecisionCriterion + { + public string Key { get; set; } + + public Dictionary> Branches { get; set; } + + public DecisionTreeNode Fallback { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterionValue.cs b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterionValue.cs new file mode 100644 index 00000000..85362800 --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterionValue.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Routing.DecisionTree +{ + internal struct DecisionCriterionValue + { + private readonly bool _isCatchAll; + private readonly object _value; + + public DecisionCriterionValue(object value, bool isCatchAll) + { + _value = value; + _isCatchAll = isCatchAll; + } + + public bool IsCatchAll + { + get { return _isCatchAll; } + } + + public object Value + { + get { return _value; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterionValueEqualityComparer.cs b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterionValueEqualityComparer.cs new file mode 100644 index 00000000..79709371 --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionCriterionValueEqualityComparer.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Routing.DecisionTree +{ + internal class DecisionCriterionValueEqualityComparer : IEqualityComparer + { + public DecisionCriterionValueEqualityComparer(IEqualityComparer innerComparer) + { + InnerComparer = innerComparer; + } + + public IEqualityComparer InnerComparer { get; private set; } + + public bool Equals(DecisionCriterionValue x, DecisionCriterionValue y) + { + return x.IsCatchAll == y.IsCatchAll || InnerComparer.Equals(x.Value, y.Value); + } + + public int GetHashCode(DecisionCriterionValue obj) + { + if (obj.IsCatchAll) + { + return 0; + } + else + { + return InnerComparer.GetHashCode(obj.Value); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionTreeBuilder.cs b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionTreeBuilder.cs new file mode 100644 index 00000000..446b591d --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionTreeBuilder.cs @@ -0,0 +1,234 @@ +// 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; + +namespace Microsoft.AspNet.Routing.DecisionTree +{ + // This code generates a minimal tree of decision criteria that map known categorical data + // (key-value-pairs) to a set of inputs. Action Selection is the best example of how this + // can be used, so the comments here will describe the process from the point-of-view, + // though the decision tree is generally applicable to like-problems. + // + // Care has been taken here to keep the performance of building the data-structure at a + // reasonable level, as this has an impact on startup cost for action selection. Additionally + // we want to hold on to the minimal amount of memory needed once we've built the tree. + // + // Ex: + // Given actions like the following, create a decision tree that will help action + // selection work efficiently. + // + // Given any set of route data it should be possible to traverse the tree using the + // presence our route data keys (like action), and whether or not they match any of + // the known values for that route data key, to find the set of actions that match + // the route data. + // + // Actions: + // + // { controller = "Home", action = "Index" } + // { controller = "Products", action = "Index" } + // { controller = "Products", action = "Buy" } + // { area = "Admin", controller = "Users", action = "AddUser" } + // + // The generated tree looks like this (json-like-notation): + // + // { + // action : { + // "AddUser" : { + // controller : { + // "Users" : { + // area : { + // "Admin" : match { area = "Admin", controller = "Users", action = "AddUser" } + // } + // } + // } + // }, + // "Buy" : { + // controller : { + // "Products" : { + // area : { + // null : match { controller = "Products", action = "Buy" } + // } + // } + // } + // }, + // "Index" : { + // controller : { + // "Home" : { + // area : { + // null : match { controller = "Home", action = "Index" } + // } + // } + // "Products" : { + // area : { + // "null" : match { controller = "Products", action = "Index" } + // } + // } + // } + // } + // } + // } + internal static class DecisionTreeBuilder + { + public static DecisionTreeNode GenerateTree(IReadOnlyList items, IClassifier classifier) + { + var itemDescriptors = new List>(); + for (var i = 0; i < items.Count; i++) + { + itemDescriptors.Add(new ItemDescriptor() + { + Criteria = classifier.GetCriteria(items[i]), + Index = i, + Item = items[i], + }); + } + + var comparer = new DecisionCriterionValueEqualityComparer(classifier.ValueComparer); + return GenerateNode( + new TreeBuilderContext(), + comparer, + itemDescriptors); + } + + private static DecisionTreeNode GenerateNode( + TreeBuilderContext context, + DecisionCriterionValueEqualityComparer comparer, + IList> items) + { + // The extreme use of generics here is intended to reduce the number of intermediate + // allocations of wrapper classes. Performance testing found that building these trees allocates + // significant memory that we can avoid and that it has a real impact on startup. + var criteria = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Matches are items that have no remaining criteria - at this point in the tree + // they are considered accepted. + var matches = new List(); + + // For each item in the working set, we want to map it to it's possible criteria-branch + // pairings, then reduce that tree to the minimal set. + foreach (var item in items) + { + var unsatisfiedCriteria = 0; + + foreach (var kvp in item.Criteria) + { + // context.CurrentCriteria is the logical 'stack' of criteria that we've already processed + // on this branch of the tree. + if (context.CurrentCriteria.Contains(kvp.Key)) + { + continue; + } + + unsatisfiedCriteria++; + + Criterion criterion; + if (!criteria.TryGetValue(kvp.Key, out criterion)) + { + criterion = new Criterion(comparer); + criteria.Add(kvp.Key, criterion); + } + + List> branch; + if (!criterion.TryGetValue(kvp.Value, out branch)) + { + branch = new List>(); + criterion.Add(kvp.Value, branch); + } + + branch.Add(item); + } + + // If all of the criteria on item are satisfied by the 'stack' then this item is a match. + if (unsatisfiedCriteria == 0) + { + matches.Add(item.Item); + } + } + + // Iterate criteria in order of branchiness to determine which one to explore next. If a criterion + // has no 'new' matches under it then we can just eliminate that part of the tree. + var reducedCriteria = new List>(); + foreach (var criterion in criteria.OrderByDescending(c => c.Value.Count)) + { + var reducedBranches = new Dictionary>(comparer.InnerComparer); + DecisionTreeNode fallback = null; + + foreach (var branch in criterion.Value) + { + var reducedItems = new List>(); + foreach (var item in branch.Value) + { + if (context.MatchedItems.Add(item)) + { + reducedItems.Add(item); + } + } + + if (reducedItems.Count > 0) + { + var childContext = new TreeBuilderContext(context); + childContext.CurrentCriteria.Add(criterion.Key); + + var newBranch = GenerateNode(childContext, comparer, branch.Value); + if (branch.Key.IsCatchAll) + { + fallback = newBranch; + } + else + { + reducedBranches.Add(branch.Key.Value, newBranch); + } + } + } + + if (reducedBranches.Count > 0 || fallback != null) + { + var newCriterion = new DecisionCriterion() + { + Key = criterion.Key, + Branches = reducedBranches, + Fallback = fallback, + }; + + reducedCriteria.Add(newCriterion); + } + } + + return new DecisionTreeNode() + { + Criteria = reducedCriteria.ToList(), + Matches = matches, + }; + } + + private class TreeBuilderContext + { + public TreeBuilderContext() + { + CurrentCriteria = new HashSet(StringComparer.OrdinalIgnoreCase); + MatchedItems = new HashSet>(); + } + + public TreeBuilderContext(TreeBuilderContext other) + { + CurrentCriteria = new HashSet(other.CurrentCriteria, StringComparer.OrdinalIgnoreCase); + MatchedItems = new HashSet>(); + } + + public HashSet CurrentCriteria { get; private set; } + + public HashSet> MatchedItems { get; private set; } + } + + // Subclass just to give a logical name to a mess of generics + private class Criterion : Dictionary>> + { + public Criterion(DecisionCriterionValueEqualityComparer comparer) + : base(comparer) + { + } + } + } +} diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionTreeNode.cs b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionTreeNode.cs new file mode 100644 index 00000000..08f5c95c --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/DecisionTreeNode.cs @@ -0,0 +1,20 @@ +// 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.AspNet.Routing.DecisionTree +{ + // Data structure representing a node in a decision tree. These are created in DecisionTreeBuilder + // and walked to find a set of items matching some input criteria. + internal class DecisionTreeNode + { + // The list of matches for the current node. This represents a set of items that have had all + // of their criteria matched if control gets to this point in the tree. + public IList Matches { get; set; } + + // Additional criteria that further branch out from this node. Walk these to fine more items + // matching the input data. + public IList> Criteria { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/IClassifier.cs b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/IClassifier.cs new file mode 100644 index 00000000..7c7749a6 --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/IClassifier.cs @@ -0,0 +1,14 @@ +// 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.AspNet.Routing.DecisionTree +{ + internal interface IClassifier + { + IDictionary GetCriteria(TItem item); + + IEqualityComparer ValueComparer { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/ItemDescriptor.cs b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/ItemDescriptor.cs new file mode 100644 index 00000000..df074291 --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/ItemDescriptor.cs @@ -0,0 +1,16 @@ +// 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.AspNet.Routing.DecisionTree +{ + internal class ItemDescriptor + { + public IDictionary Criteria { get; set; } + + public int Index { get; set; } + + public TItem Item { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/Microsoft.AspNet.Routing.DecisionTree.Sources.xproj b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/Microsoft.AspNet.Routing.DecisionTree.Sources.xproj new file mode 100644 index 00000000..1cdad83d --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/Microsoft.AspNet.Routing.DecisionTree.Sources.xproj @@ -0,0 +1,20 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + abd5aa59-6000-4a3d-a54f-4b636f725ae8 + Microsoft.AspNet.Routing.DecisionTree + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + + 2.0 + + + diff --git a/src/Microsoft.AspNet.Routing.DecisionTree.Sources/project.json b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/project.json new file mode 100644 index 00000000..de7d56a2 --- /dev/null +++ b/src/Microsoft.AspNet.Routing.DecisionTree.Sources/project.json @@ -0,0 +1,23 @@ +{ + "description": "Components for building a DecisionTree.", + "version": "1.0.0-*", + "repository": { + "type": "git", + "url": "git://github.com/aspnet/routing" + }, + "compilationOptions": { + "warningsAsErrors": true, + "keyFile": "../../tools/Key.snk" + }, + "shared": "**/*.cs", + "frameworks": { + "net451": { }, + "dotnet5.4": { + "dependencies": { + "System.Collections": "4.0.10", + "System.Linq": "4.0.0", + "System.Runtime": "4.0.20" + } + } + } +} diff --git a/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteBuilder.cs b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteBuilder.cs new file mode 100644 index 00000000..7a838d2d --- /dev/null +++ b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteBuilder.cs @@ -0,0 +1,165 @@ +// 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.Diagnostics; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNet.Routing.Tree +{ + public class TreeRouteBuilder + { + private readonly IRouter _target; + private readonly List _generatingEntries; + private readonly List _matchingEntries; + + private readonly ILogger _logger; + private readonly ILogger _constraintLogger; + + public TreeRouteBuilder(IRouter target, ILoggerFactory loggerFactory) + { + _target = target; + _generatingEntries = new List(); + _matchingEntries = new List(); + + _logger = loggerFactory.CreateLogger(); + _constraintLogger = loggerFactory.CreateLogger(typeof(RouteConstraintMatcher).FullName); + } + + public void Add(TreeRouteLinkGenerationEntry entry) + { + _generatingEntries.Add(entry); + } + + public void Add(TreeRouteMatchingEntry entry) + { + _matchingEntries.Add(entry); + } + + public TreeRouter Build(int version) + { + var trees = new Dictionary(); + + foreach (var entry in _matchingEntries) + { + UrlMatchingTree tree; + if (!trees.TryGetValue(entry.Order, out tree)) + { + tree = new UrlMatchingTree(entry.Order); + trees.Add(entry.Order, tree); + } + + AddEntryToTree(tree, entry); + } + + return new TreeRouter( + _target, + trees.Values.OrderBy(tree => tree.Order).ToArray(), + _generatingEntries, + _logger, + _constraintLogger, + version); + } + + public void Clear() + { + _generatingEntries.Clear(); + _matchingEntries.Clear(); + } + + private void AddEntryToTree(UrlMatchingTree tree, TreeRouteMatchingEntry entry) + { + var current = tree.Root; + + for (var i = 0; i < entry.RouteTemplate.Segments.Count; i++) + { + var segment = entry.RouteTemplate.Segments[i]; + if (!segment.IsSimple) + { + // Treat complex segments as a constrained parameter + if (current.ConstrainedParameters == null) + { + current.ConstrainedParameters = new UrlMatchingNode(length: i + 1); + } + + current = current.ConstrainedParameters; + continue; + } + + Debug.Assert(segment.Parts.Count == 1); + var part = segment.Parts[0]; + if (part.IsLiteral) + { + UrlMatchingNode next; + if (!current.Literals.TryGetValue(part.Text, out next)) + { + next = new UrlMatchingNode(length: i + 1); + current.Literals.Add(part.Text, next); + } + + current = next; + continue; + } + + if (part.IsParameter && (part.IsOptional || part.IsCatchAll)) + { + current.Matches.Add(entry); + } + + if (part.IsParameter && part.InlineConstraints.Any() && !part.IsCatchAll) + { + if (current.ConstrainedParameters == null) + { + current.ConstrainedParameters = new UrlMatchingNode(length: i + 1); + } + + current = current.ConstrainedParameters; + continue; + } + + if (part.IsParameter && !part.IsCatchAll) + { + if (current.Parameters == null) + { + current.Parameters = new UrlMatchingNode(length: i + 1); + } + + current = current.Parameters; + continue; + } + + if (part.IsParameter && part.InlineConstraints.Any() && part.IsCatchAll) + { + if (current.ConstrainedCatchAlls == null) + { + current.ConstrainedCatchAlls = new UrlMatchingNode(length: i + 1); + } + + current = current.ConstrainedCatchAlls; + continue; + } + + if (part.IsParameter && part.IsCatchAll) + { + if (current.CatchAlls == null) + { + current.CatchAlls = new UrlMatchingNode(length: i + 1); + } + + current = current.CatchAlls; + continue; + } + + Debug.Fail("We shouldn't get here."); + } + + current.Matches.Add(entry); + current.Matches.Sort((x, y) => + { + var result = x.Precedence.CompareTo(y.Precedence); + return result == 0 ? x.RouteTemplate.TemplateText.CompareTo(y.RouteTemplate.TemplateText) : result; + }); + } + } +} diff --git a/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteLinkGenerationEntry.cs b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteLinkGenerationEntry.cs new file mode 100644 index 00000000..15f06da2 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteLinkGenerationEntry.cs @@ -0,0 +1,60 @@ +// 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 Microsoft.AspNet.Routing.Template; + +namespace Microsoft.AspNet.Routing.Tree +{ + /// + /// Used to build a . Represents an individual URL-generating route that will be + /// aggregated into the . + /// + public class TreeRouteLinkGenerationEntry + { + /// + /// The . + /// + public TemplateBinder Binder { get; set; } + + /// + /// The route constraints. + /// + public IReadOnlyDictionary Constraints { get; set; } + + /// + /// The route defaults. + /// + public IReadOnlyDictionary Defaults { get; set; } + + /// + /// The order of the template. + /// + public int Order { get; set; } + + /// + /// The precedence of the template for link generation. Greater number means higher precedence. + /// + public decimal GenerationPrecedence { get; set; } + + /// + /// The name of the route. + /// + public string Name { 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 RouteTemplate Template { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteMatchingEntry.cs b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteMatchingEntry.cs new file mode 100644 index 00000000..3a0e99e8 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouteMatchingEntry.cs @@ -0,0 +1,50 @@ +// 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 Microsoft.AspNet.Routing.Template; + +namespace Microsoft.AspNet.Routing.Tree +{ + /// + /// Used to build an . Represents an individual URL-matching route that will be + /// aggregated into the . + /// + public class TreeRouteMatchingEntry + { + /// + /// The order of the template. + /// + public int Order { get; set; } + + /// + /// The precedence of the template. + /// + public decimal Precedence { get; set; } + + /// + /// The to invoke when this entry matches. + /// + public IRouter Target { get; set; } + + /// + /// The name of the route. + /// + public string RouteName { get; set; } + + /// + /// The . + /// + public RouteTemplate RouteTemplate { get; set; } + + /// + /// The . + /// + public TemplateMatcher TemplateMatcher { get; set; } + + /// + /// The route constraints. + /// + public IReadOnlyDictionary Constraints { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouter.cs b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouter.cs new file mode 100644 index 00000000..2449303d --- /dev/null +++ b/src/Microsoft.AspNet.Routing/AttributeRouting/TreeRouter.cs @@ -0,0 +1,478 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.Routing.Internal; +using Microsoft.AspNet.Routing.Logging; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNet.Routing.Tree +{ + /// + /// An implementation for attribute routing. + /// + public class TreeRouter : IRouter + { + // Key used by routing and action selection to match an attribute route entry to a + // group of action descriptors. + public static readonly string RouteGroupKey = "!__route_group"; + + private readonly IRouter _next; + private readonly LinkGenerationDecisionTree _linkGenerationTree; + private readonly UrlMatchingTree[] _trees; + private readonly IDictionary _namedEntries; + + private readonly ILogger _logger; + private readonly ILogger _constraintLogger; + + /// + /// Creates a new . + /// + /// The next router. Invoked when a route entry matches. + /// The list of that contains the route entries. + /// The set of . + /// The instance. + /// The instance used + /// in . + /// The version of this route. + public TreeRouter( + IRouter next, + UrlMatchingTree[] trees, + IEnumerable linkGenerationEntries, + ILogger routeLogger, + ILogger constraintLogger, + int version) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + if (trees == null) + { + throw new ArgumentNullException(nameof(trees)); + } + + if (linkGenerationEntries == null) + { + throw new ArgumentNullException(nameof(linkGenerationEntries)); + } + + if (routeLogger == null) + { + throw new ArgumentNullException(nameof(routeLogger)); + } + + if (constraintLogger == null) + { + throw new ArgumentNullException(nameof(constraintLogger)); + } + + _next = next; + _trees = trees; + _logger = routeLogger; + _constraintLogger = constraintLogger; + + var namedEntries = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + foreach (var entry in linkGenerationEntries) + { + // Skip unnamed entries + if (entry.Name == null) + { + continue; + } + + // We only need to keep one AttributeRouteLinkGenerationEntry per route template + // so in case two entries have the same name and the same template we only keep + // the first entry. + TreeRouteLinkGenerationEntry namedEntry = null; + if (namedEntries.TryGetValue(entry.Name, out namedEntry) && + !namedEntry.Template.TemplateText.Equals(entry.Template.TemplateText, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + Resources.FormatAttributeRoute_DifferentLinkGenerationEntries_SameName(entry.Name), + nameof(linkGenerationEntries)); + } + else if (namedEntry == null) + { + namedEntries.Add(entry.Name, entry); + } + } + + _namedEntries = namedEntries; + + // The decision tree will take care of ordering for these entries. + _linkGenerationTree = new LinkGenerationDecisionTree(linkGenerationEntries.ToArray()); + + Version = version; + } + + /// + /// Gets the version of this route. + /// + public int Version { get; } + + /// + public VirtualPathData GetVirtualPath(VirtualPathContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If it's a named route we will try to generate a link directly and + // if we can't, we will not try to generate it using an unnamed route. + if (context.RouteName != null) + { + return GetVirtualPathForNamedRoute(context); + } + + // The decision tree will give us back all entries that match the provided route data in the correct + // order. We just need to iterate them and use the first one that can generate a link. + var matches = _linkGenerationTree.GetMatches(context); + + foreach (var match in matches) + { + var path = GenerateVirtualPath(context, match.Entry); + if (path != null) + { + context.IsBound = true; + return path; + } + } + + return null; + } + + /// + public async Task RouteAsync(RouteContext context) + { + foreach (var tree in _trees) + { + var tokenizer = new PathTokenizer(context.HttpContext.Request.Path); + var enumerator = tokenizer.GetEnumerator(); + var root = tree.Root; + + var treeEnumerator = new TreeEnumerator(root, tokenizer); + + while (treeEnumerator.MoveNext()) + { + var node = treeEnumerator.Current; + foreach (var item in node.Matches) + { + var values = item.TemplateMatcher.Match(context.HttpContext.Request.Path); + if (values == null) + { + continue; + } + + var match = new TemplateMatch(item, values); + + var oldRouteData = context.RouteData; + + var newRouteData = new RouteData(oldRouteData); + + newRouteData.Routers.Add(match.Entry.Target); + MergeValues(newRouteData.Values, match.Values); + + if (!RouteConstraintMatcher.Match( + match.Entry.Constraints, + newRouteData.Values, + context.HttpContext, + this, + RouteDirection.IncomingRequest, + _constraintLogger)) + { + return; + } + + _logger.MatchedRouteName(match.Entry.RouteName, match.Entry.RouteTemplate.TemplateText); + + context.RouteData = newRouteData; + + try + { + await match.Entry.Target.RouteAsync(context); + } + finally + { + if (!context.IsHandled) + { + // Restore the original values to prevent polluting the route data. + context.RouteData = oldRouteData; + } + } + + if (context.IsHandled) + { + return; + } + } + } + } + } + + private struct TreeEnumerator : IEnumerator + { + private readonly Stack _stack; + private readonly PathTokenizer _tokenizer; + + private int _segmentIndex; + + public TreeEnumerator(UrlMatchingNode root, PathTokenizer tokenizer) + { + _stack = new Stack(); + _tokenizer = tokenizer; + Current = null; + _segmentIndex = -1; + + _stack.Push(root); + } + + public UrlMatchingNode Current { get; private set; } + + object IEnumerator.Current => Current; + + public void Dispose() + { + } + + public bool MoveNext() + { + if (_stack == null) + { + return false; + } + + while (_stack.Count > 0) + { + var next = _stack.Pop(); + if (++_segmentIndex >= _tokenizer.Count) + { + _segmentIndex--; + if (next.Matches.Count > 0) + { + Current = next; + return true; + } + } + + if (_tokenizer.Count == 0) + { + continue; + } + + if (next.CatchAlls != null) + { + _stack.Push(next.CatchAlls); + } + + if (next.ConstrainedCatchAlls != null) + { + _stack.Push(next.ConstrainedCatchAlls); + } + + if (next.Parameters != null) + { + _stack.Push(next.Parameters); + } + + if (next.ConstrainedParameters != null) + { + _stack.Push(next.ConstrainedParameters); + } + + if (next.Literals.Count > 0) + { + UrlMatchingNode node; + if (next.Literals.TryGetValue(_tokenizer[_segmentIndex].Value, out node)) + { + _stack.Push(node); + } + } + } + + return false; + } + + public void Reset() + { + _stack.Clear(); + Current = null; + _segmentIndex = -1; + } + } + + private static void MergeValues( + IDictionary destination, + IDictionary values) + { + foreach (var kvp in values) + { + if (kvp.Value != null) + { + // This will replace the original value for the specified key. + // Values from the matched route will take preference over previous + // data in the route context. + destination[kvp.Key] = kvp.Value; + } + } + } + + private struct TemplateMatch : IEquatable + { + public TemplateMatch(TreeRouteMatchingEntry entry, IDictionary values) + { + Entry = entry; + Values = values; + } + + public TreeRouteMatchingEntry Entry { get; } + + public IDictionary Values { get; } + + public override bool Equals(object obj) + { + if (obj is TemplateMatch) + { + return Equals((TemplateMatch)obj); + } + + return false; + } + + public bool Equals(TemplateMatch other) + { + return + object.ReferenceEquals(Entry, other.Entry) && + object.ReferenceEquals(Values, other.Values); + } + + public override int GetHashCode() + { + var hash = new HashCodeCombiner(); + hash.Add(Entry); + hash.Add(Values); + return hash.CombinedHash; + } + + public static bool operator ==(TemplateMatch left, TemplateMatch right) + { + return left.Equals(right); + } + + public static bool operator !=(TemplateMatch left, TemplateMatch right) + { + return !left.Equals(right); + } + } + + private VirtualPathData GetVirtualPathForNamedRoute(VirtualPathContext context) + { + TreeRouteLinkGenerationEntry entry; + if (_namedEntries.TryGetValue(context.RouteName, out entry)) + { + var path = GenerateVirtualPath(context, entry); + if (path != null) + { + context.IsBound = true; + return path; + } + } + return null; + } + + private VirtualPathData GenerateVirtualPath(VirtualPathContext context, TreeRouteLinkGenerationEntry 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, + _constraintLogger); + + if (!matched) + { + // A constraint 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(RouteGroupKey, entry.RouteGroup); + + var childContext = new VirtualPathContext(context.Context, context.AmbientValues, context.Values) + { + ProvidedValues = providedValues, + }; + + var pathData = _next.GetVirtualPath(childContext); + if (pathData != null) + { + // If path is non-null then the target router short-circuited, we don't expect this + // in typical MVC scenarios. + return pathData; + } + else if (!childContext.IsBound) + { + // The target router has rejected these values. We don't expect this in typical MVC scenarios. + return null; + } + + var path = entry.Binder.BindValues(bindingResult.AcceptedValues); + if (path == null) + { + return null; + } + + return new VirtualPathData(this, path); + } + } +} diff --git a/src/Microsoft.AspNet.Routing/AttributeRouting/UrlMatchingNode.cs b/src/Microsoft.AspNet.Routing/AttributeRouting/UrlMatchingNode.cs new file mode 100644 index 00000000..240de8a3 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/AttributeRouting/UrlMatchingNode.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNet.Routing.Tree +{ + public class UrlMatchingNode + { + public UrlMatchingNode(int length) + { + Length = length; + + Matches = new List(); + Literals = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public int Length { get; } + + // These entries are sorted by precedence then template + public List Matches { get; } + + public Dictionary Literals { get; } + + public UrlMatchingNode ConstrainedParameters { get; set; } + + public UrlMatchingNode Parameters { get; set; } + + public UrlMatchingNode ConstrainedCatchAlls { get; set; } + + public UrlMatchingNode CatchAlls { get; set; } + } +} diff --git a/src/Microsoft.AspNet.Routing/AttributeRouting/UrlMatchingTree.cs b/src/Microsoft.AspNet.Routing/AttributeRouting/UrlMatchingTree.cs new file mode 100644 index 00000000..78881246 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/AttributeRouting/UrlMatchingTree.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Routing.Tree +{ + public class UrlMatchingTree + { + public UrlMatchingTree(int order) + { + Order = order; + } + + public int Order { get; } + + public UrlMatchingNode Root { get; } = new UrlMatchingNode(length: 0); + } +} diff --git a/src/Microsoft.AspNet.Routing/Internal/LinkGenerationDecisionTree.cs b/src/Microsoft.AspNet.Routing/Internal/LinkGenerationDecisionTree.cs new file mode 100644 index 00000000..4ae1da2e --- /dev/null +++ b/src/Microsoft.AspNet.Routing/Internal/LinkGenerationDecisionTree.cs @@ -0,0 +1,155 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNet.Routing.DecisionTree; +using Microsoft.AspNet.Routing.Tree; + +namespace Microsoft.AspNet.Routing.Internal +{ + // A decision tree that matches link generation entries based on route data. + public class LinkGenerationDecisionTree + { + private readonly DecisionTreeNode _root; + + public LinkGenerationDecisionTree(IReadOnlyList entries) + { + _root = DecisionTreeBuilder.GenerateTree( + entries, + new AttributeRouteLinkGenerationEntryClassifier()); + } + + public IList GetMatches(VirtualPathContext context) + { + var results = new List(); + Walk(results, context, _root, isFallbackPath: false); + results.Sort(LinkGenerationMatchComparer.Instance); + return results; + } + + // We need to recursively walk the decision tree based on the provided route data + // (context.Values + context.AmbientValues) to find all entries that match. This process is + // virtually identical to action selection. + // + // Each entry has a collection of 'required link values' that must be satisfied. These are + // key-value pairs that make up the decision tree. + // + // A 'require link value' is considered satisfied IF: + // 1. The value in context.Values matches the required value OR + // 2. There is no value in context.Values and the value in context.AmbientValues matches OR + // 3. The required value is 'null' and there is no value in context.Values. + // + // Ex: + // entry requires { area = null, controller = Store, action = Buy } + // context.Values = { controller = Store, action = Buy } + // context.AmbientValues = { area = Help, controller = AboutStore, action = HowToBuyThings } + // + // In this case the entry is a match. The 'controller' and 'action' are both supplied by context.Values, + // and the 'area' is satisfied because there's NOT a value in context.Values. It's OK to ignore ambient + // values in link generation. + // + // If another entry existed like { area = Help, controller = Store, action = Buy }, this would also + // match. + // + // The decision tree uses a tree data structure to execute these rules across all candidates at once. + private void Walk( + List results, + VirtualPathContext context, + DecisionTreeNode node, + bool isFallbackPath) + { + // Any entries in node.Matches have had all their required values satisfied, so add them + // to the results. + for (var i = 0; i < node.Matches.Count; i++) + { + results.Add(new LinkGenerationMatch(node.Matches[i], isFallbackPath)); + } + + for (var i = 0; i < node.Criteria.Count; i++) + { + var criterion = node.Criteria[i]; + var key = criterion.Key; + + object value; + if (context.Values.TryGetValue(key, out value)) + { + DecisionTreeNode branch; + if (criterion.Branches.TryGetValue(value ?? string.Empty, out branch)) + { + Walk(results, context, branch, isFallbackPath); + } + } + else + { + // If a value wasn't explicitly supplied, match BOTH the ambient value and the empty value + // if an ambient value was supplied. The path explored with the empty value is considered + // the fallback path. + DecisionTreeNode branch; + if (context.AmbientValues.TryGetValue(key, out value) && + !criterion.Branches.Comparer.Equals(value, string.Empty)) + { + if (criterion.Branches.TryGetValue(value, out branch)) + { + Walk(results, context, branch, isFallbackPath); + } + } + + if (criterion.Branches.TryGetValue(string.Empty, out branch)) + { + Walk(results, context, branch, isFallbackPath: true); + } + } + } + } + + private class AttributeRouteLinkGenerationEntryClassifier : IClassifier + { + public AttributeRouteLinkGenerationEntryClassifier() + { + ValueComparer = new RouteValueEqualityComparer(); + } + + public IEqualityComparer ValueComparer { get; private set; } + + public IDictionary GetCriteria(TreeRouteLinkGenerationEntry item) + { + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in item.RequiredLinkValues) + { + results.Add(kvp.Key, new DecisionCriterionValue(kvp.Value ?? string.Empty, isCatchAll: false)); + } + + return results; + } + } + + private class LinkGenerationMatchComparer : IComparer + { + public static readonly LinkGenerationMatchComparer Instance = new LinkGenerationMatchComparer(); + + public int Compare(LinkGenerationMatch x, LinkGenerationMatch y) + { + // For this comparison lower is better. + if (x.Entry.Order != y.Entry.Order) + { + return x.Entry.Order.CompareTo(y.Entry.Order); + } + + if (x.Entry.GenerationPrecedence != y.Entry.GenerationPrecedence) + { + // Reversed because higher is better + return y.Entry.GenerationPrecedence.CompareTo(x.Entry.GenerationPrecedence); + } + + if (x.IsFallbackMatch != y.IsFallbackMatch) + { + // A fallback match is worse than a non-fallback + return x.IsFallbackMatch.CompareTo(y.IsFallbackMatch); + } + + return StringComparer.Ordinal.Compare(x.Entry.Template.TemplateText, y.Entry.Template.TemplateText); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing/Internal/LinkGenerationMatch.cs b/src/Microsoft.AspNet.Routing/Internal/LinkGenerationMatch.cs new file mode 100644 index 00000000..d4c08d91 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/Internal/LinkGenerationMatch.cs @@ -0,0 +1,23 @@ +// 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.AspNet.Routing.Tree; + +namespace Microsoft.AspNet.Routing.Internal +{ + public struct LinkGenerationMatch + { + private readonly bool _isFallbackMatch; + private readonly TreeRouteLinkGenerationEntry _entry; + + public LinkGenerationMatch(TreeRouteLinkGenerationEntry entry, bool isFallbackMatch) + { + _entry = entry; + _isFallbackMatch = isFallbackMatch; + } + + public TreeRouteLinkGenerationEntry Entry { get { return _entry; } } + + public bool IsFallbackMatch { get { return _isFallbackMatch; } } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing/Logging/TreeRouterLoggerExtensions.cs b/src/Microsoft.AspNet.Routing/Logging/TreeRouterLoggerExtensions.cs new file mode 100644 index 00000000..d5ecf229 --- /dev/null +++ b/src/Microsoft.AspNet.Routing/Logging/TreeRouterLoggerExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNet.Routing.Logging +{ + internal static class TreeRouterLoggerExtensions + { + private static readonly Action _matchedRouteName; + + static TreeRouterLoggerExtensions() + { + _matchedRouteName = LoggerMessage.Define( + LogLevel.Verbose, + 1, + "Request successfully matched the route with name '{RouteName}' and template '{RouteTemplate}'."); + } + + public static void MatchedRouteName( + this ILogger logger, + string routeName, + string routeTemplate) + { + _matchedRouteName(logger, routeName, routeTemplate, null); + } + } +} diff --git a/src/Microsoft.AspNet.Routing/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Routing/Properties/Resources.Designer.cs index 8da79002..4bcd9e7d 100644 --- a/src/Microsoft.AspNet.Routing/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Routing/Properties/Resources.Designer.cs @@ -410,6 +410,22 @@ internal static string FormatTemplateRoute_OptionalParameterHasTobeTheLast(objec return string.Format(CultureInfo.CurrentCulture, GetString("TemplateRoute_OptionalParameterHasTobeTheLast"), p0, p1, p2); } + /// + /// Two or more routes named '{0}' have different templates. + /// + internal static string AttributeRoute_DifferentLinkGenerationEntries_SameName + { + get { return GetString("AttributeRoute_DifferentLinkGenerationEntries_SameName"); } + } + + /// + /// Two or more routes named '{0}' have different templates. + /// + internal static string FormatAttributeRoute_DifferentLinkGenerationEntries_SameName(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("AttributeRoute_DifferentLinkGenerationEntries_SameName"), p0); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Routing/Resources.resx b/src/Microsoft.AspNet.Routing/Resources.resx index b65a2d69..1686265a 100644 --- a/src/Microsoft.AspNet.Routing/Resources.resx +++ b/src/Microsoft.AspNet.Routing/Resources.resx @@ -192,4 +192,7 @@ An optional parameter must be at the end of the segment. In the segment '{0}', optional parameter '{1}' is followed by '{2}'. + + Two or more routes named '{0}' have different templates. + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing/RouteValueEqualityComparer.cs b/src/Microsoft.AspNet.Routing/RouteValueEqualityComparer.cs new file mode 100644 index 00000000..2710247f --- /dev/null +++ b/src/Microsoft.AspNet.Routing/RouteValueEqualityComparer.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Microsoft.AspNet.Routing +{ + /// + /// An implementation that compares objects as-if + /// they were route value strings. + /// + /// + /// Values that are are not strings are converted to strings using + /// Convert.ToString(x, CultureInfo.InvariantCulture). null values are converted + /// to the empty string. + /// + /// strings are compared using . + /// + public class RouteValueEqualityComparer : IEqualityComparer + { + /// + public new bool Equals(object x, object y) + { + var stringX = x as string ?? Convert.ToString(x, CultureInfo.InvariantCulture); + var stringY = y as string ?? Convert.ToString(y, CultureInfo.InvariantCulture); + + if (string.IsNullOrEmpty(stringX) && string.IsNullOrEmpty(stringY)) + { + return true; + } + else + { + return string.Equals(stringX, stringY, StringComparison.OrdinalIgnoreCase); + } + } + + /// + public int GetHashCode(object obj) + { + var stringObj = obj as string ?? Convert.ToString(obj, CultureInfo.InvariantCulture); + if (string.IsNullOrEmpty(stringObj)) + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(string.Empty); + } + else + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(stringObj); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing/Template/RoutePrecedence.cs b/src/Microsoft.AspNet.Routing/Template/RoutePrecedence.cs new file mode 100644 index 00000000..9980394a --- /dev/null +++ b/src/Microsoft.AspNet.Routing/Template/RoutePrecedence.cs @@ -0,0 +1,131 @@ +// 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.Diagnostics; +using System.Linq; + +namespace Microsoft.AspNet.Routing.Template +{ + /// + /// Computes precedence for an attribute route template. + /// + public static class RoutePrecedence + { + // Compute the precedence for matching a provided url + // e.g.: /api/template == 1.1 + // /api/template/{id} == 1.13 + // /api/{id:int} == 1.2 + // /api/template/{id:int} == 1.12 + public static decimal ComputeMatched(RouteTemplate template) + { + // Each precedence digit corresponds to one decimal place. For example, 3 segments with precedences 2, 1, + // and 4 results in a combined precedence of 2.14 (decimal). + var precedence = 0m; + + for (var i = 0; i < template.Segments.Count; i++) + { + var segment = template.Segments[i]; + + var digit = ComputeMatchDigit(segment); + Debug.Assert(digit >= 0 && digit < 10); + + precedence += decimal.Divide(digit, (decimal)Math.Pow(10, i)); + } + + return precedence; + } + + // Compute the precedence for generating a url + // e.g.: /api/template == 5.5 + // /api/template/{id} == 5.53 + // /api/{id:int} == 5.4 + // /api/template/{id:int} == 5.54 + public static decimal ComputeGenerated(RouteTemplate template) + { + // Each precedence digit corresponds to one decimal place. For example, 3 segments with precedences 2, 1, + // and 4 results in a combined precedence of 2.14 (decimal). + var precedence = 0m; + + for (var i = 0; i < template.Segments.Count; i++) + { + var segment = template.Segments[i]; + + var digit = ComputeGenerationDigit(segment); + Debug.Assert(digit >= 0 && digit < 10); + + precedence += decimal.Divide(digit, (decimal)Math.Pow(10, i)); + } + + return precedence; + } + + // Segments have the following order: + // 5 - Literal segments + // 4 - Multi-part segments && Constrained parameter segments + // 3 - Unconstrained parameter segements + // 2 - Constrained wildcard parameter segments + // 1 - Unconstrained wildcard parameter segments + private static int ComputeGenerationDigit(TemplateSegment segment) + { + if(segment.Parts.Count > 1) + { + return 4; + } + + var part = segment.Parts[0]; + if(part.IsLiteral) + { + return 5; + } + else + { + Debug.Assert(part.IsParameter); + var digit = part.IsCatchAll ? 1 : 3; + + if (part.InlineConstraints != null && part.InlineConstraints.Any()) + { + digit++; + } + + return digit; + } + } + + // Segments have the following order: + // 1 - Literal segments + // 2 - Constrained parameter segments / Multi-part segments + // 3 - Unconstrained parameter segments + // 4 - Constrained wildcard parameter segments + // 5 - Unconstrained wildcard parameter segments + private static int ComputeMatchDigit(TemplateSegment segment) + { + if (segment.Parts.Count > 1) + { + // Multi-part segments should appear after literal segments and along with parameter segments + return 2; + } + + var part = segment.Parts[0]; + // Literal segments always go first + if (part.IsLiteral) + { + return 1; + } + else + { + Debug.Assert(part.IsParameter); + var digit = part.IsCatchAll ? 5 : 3; + + // If there is a route constraint for the parameter, reduce order by 1 + // Constrained parameters end up with order 2, Constrained catch alls end up with order 4 + if (part.InlineConstraints != null && part.InlineConstraints.Any()) + { + digit--; + } + + return digit; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Routing/Template/RouteTemplate.cs b/src/Microsoft.AspNet.Routing/Template/RouteTemplate.cs index 813103f6..69f1c89e 100644 --- a/src/Microsoft.AspNet.Routing/Template/RouteTemplate.cs +++ b/src/Microsoft.AspNet.Routing/Template/RouteTemplate.cs @@ -20,7 +20,7 @@ public RouteTemplate(string template, List segments) throw new ArgumentNullException(nameof(segments)); } - Template = template; + TemplateText = template; Segments = segments; @@ -39,7 +39,7 @@ public RouteTemplate(string template, List segments) } } - public string Template { get; } + public string TemplateText { get; } public IList Parameters { get; } diff --git a/src/Microsoft.AspNet.Routing/project.json b/src/Microsoft.AspNet.Routing/project.json index f99a07df..1cdf02f1 100644 --- a/src/Microsoft.AspNet.Routing/project.json +++ b/src/Microsoft.AspNet.Routing/project.json @@ -9,11 +9,19 @@ "warningsAsErrors": true, "keyFile": "../../tools/Key.snk" }, - "dependencies": { - "Microsoft.AspNet.Http.Extensions": "1.0.0-*", - "Microsoft.Extensions.Logging.Abstractions": "1.0.0-*", - "Microsoft.Extensions.OptionsModel": "1.0.0-*" - }, + "dependencies": { + "Microsoft.AspNet.Http.Extensions": "1.0.0-*", + "Microsoft.AspNet.Routing.DecisionTree.Sources": { + "type": "build", + "version": "1.0.0-*" + }, + "Microsoft.Extensions.HashCodeCombiner.Sources": { + "type": "build", + "version": "1.0.0-*" + }, + "Microsoft.Extensions.Logging.Abstractions": "1.0.0-*", + "Microsoft.Extensions.OptionsModel": "1.0.0-*" + }, "frameworks": { "net451": {}, "dotnet5.4": { diff --git a/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/DecisionTreeBuilderTest.cs b/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/DecisionTreeBuilderTest.cs new file mode 100644 index 00000000..3da8b6d9 --- /dev/null +++ b/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/DecisionTreeBuilderTest.cs @@ -0,0 +1,285 @@ +// 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 Xunit; + +namespace Microsoft.AspNet.Routing.DecisionTree +{ + public class DecisionTreeBuilderTest + { + [Fact] + public void BuildTree_Empty() + { + // Arrange + var items = new List(); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Criteria); + Assert.Empty(tree.Matches); + } + + [Fact] + public void BuildTree_TrivialMatch() + { + // Arrange + var items = new List(); + + var item = new Item(); + items.Add(item); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Criteria); + Assert.Same(item, Assert.Single(tree.Matches)); + } + + [Fact] + public void BuildTree_WithMultipleCriteria() + { + // Arrange + var items = new List(); + + var item = new Item(); + item.Criteria.Add("area", new DecisionCriterionValue(value: "Admin", isCatchAll: false)); + item.Criteria.Add("controller", new DecisionCriterionValue(value: "Users", isCatchAll: false)); + item.Criteria.Add("action", new DecisionCriterionValue(value: "AddUser", isCatchAll: false)); + items.Add(item); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var area = Assert.Single(tree.Criteria); + Assert.Equal("area", area.Key); + Assert.Null(area.Fallback); + + var admin = Assert.Single(area.Branches); + Assert.Equal("Admin", admin.Key); + Assert.Empty(admin.Value.Matches); + + var controller = Assert.Single(admin.Value.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + var users = Assert.Single(controller.Branches); + Assert.Equal("Users", users.Key); + Assert.Empty(users.Value.Matches); + + var action = Assert.Single(users.Value.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var addUser = Assert.Single(action.Branches); + Assert.Equal("AddUser", addUser.Key); + Assert.Empty(addUser.Value.Criteria); + Assert.Same(item, Assert.Single(addUser.Value.Matches)); + } + + [Fact] + public void BuildTree_WithMultipleItems() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var action = Assert.Single(tree.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var buy = action.Branches["Buy"]; + Assert.Empty(buy.Matches); + + var controller = Assert.Single(buy.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + var store = Assert.Single(controller.Branches); + Assert.Equal("Store", store.Key); + Assert.Empty(store.Value.Criteria); + Assert.Same(item1, Assert.Single(store.Value.Matches)); + + var checkout = action.Branches["Checkout"]; + Assert.Empty(checkout.Matches); + + controller = Assert.Single(checkout.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + store = Assert.Single(controller.Branches); + Assert.Equal("Store", store.Key); + Assert.Empty(store.Value.Criteria); + Assert.Same(item2, Assert.Single(store.Value.Matches)); + } + + [Fact] + public void BuildTree_WithInteriorMatch() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + var item3 = new Item(); + item3.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item3); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var action = Assert.Single(tree.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var buy = action.Branches["Buy"]; + Assert.Same(item3, Assert.Single(buy.Matches)); + } + + [Fact] + public void BuildTree_WithCatchAll() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("country", new DecisionCriterionValue(value: "CA", isCatchAll: false)); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("country", new DecisionCriterionValue(value: "US", isCatchAll: false)); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + var item3 = new Item(); + item3.Criteria.Add("country", new DecisionCriterionValue(value: null, isCatchAll: true)); + item3.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item3.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item3); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var country = Assert.Single(tree.Criteria); + Assert.Equal("country", country.Key); + + var fallback = country.Fallback; + Assert.NotNull(fallback); + + var controller = Assert.Single(fallback.Criteria); + Assert.Equal("controller", controller.Key); + Assert.Null(controller.Fallback); + + var store = Assert.Single(controller.Branches); + Assert.Equal("Store", store.Key); + Assert.Empty(store.Value.Matches); + + var action = Assert.Single(store.Value.Criteria); + Assert.Equal("action", action.Key); + Assert.Null(action.Fallback); + + var checkout = Assert.Single(action.Branches); + Assert.Equal("Checkout", checkout.Key); + Assert.Empty(checkout.Value.Criteria); + Assert.Same(item3, Assert.Single(checkout.Value.Matches)); + } + + [Fact] + public void BuildTree_WithDivergentCriteria() + { + // Arrange + var items = new List(); + + var item1 = new Item(); + item1.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item1.Criteria.Add("action", new DecisionCriterionValue(value: "Buy", isCatchAll: false)); + items.Add(item1); + + var item2 = new Item(); + item2.Criteria.Add("controller", new DecisionCriterionValue(value: "Store", isCatchAll: false)); + item2.Criteria.Add("action", new DecisionCriterionValue(value: "Checkout", isCatchAll: false)); + items.Add(item2); + + var item3 = new Item(); + item3.Criteria.Add("stub", new DecisionCriterionValue(value: "Bleh", isCatchAll: false)); + items.Add(item3); + + // Act + var tree = DecisionTreeBuilder.GenerateTree(items, new ItemClassifier()); + + // Assert + Assert.Empty(tree.Matches); + + var action = tree.Criteria[0]; + Assert.Equal("action", action.Key); + + var stub = tree.Criteria[1]; + Assert.Equal("stub", stub.Key); + } + + private class Item + { + public Item() + { + Criteria = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public Dictionary Criteria { get; private set; } + } + + private class ItemClassifier : IClassifier + { + public IEqualityComparer ValueComparer + { + get + { + return new RouteValueEqualityComparer(); + } + } + + public IDictionary GetCriteria(Item item) + { + return item.Criteria; + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests.xproj b/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests.xproj new file mode 100644 index 00000000..36ad4bad --- /dev/null +++ b/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 09c2933c-23ac-41b7-994d-e8a5184a629c + Microsoft.AspNet.Routing.DecisionTree.Tests + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/project.json b/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/project.json new file mode 100644 index 00000000..098d979e --- /dev/null +++ b/test/Microsoft.AspNet.Routing.DecisionTree.Sources.Tests/project.json @@ -0,0 +1,21 @@ +{ + "compilationOptions": { + "warningsAsErrors": true + }, + "dependencies": { + "Microsoft.AspNet.Routing": "1.0.0-*", + "Microsoft.AspNet.Routing.DecisionTree.Sources": { + "type": "build", + "version": "1.0.0-*" + }, + "Microsoft.AspNet.Testing": "1.0.0-*", + "xunit.runner.aspnet": "2.0.0-aspnet-*" + }, + "frameworks": { + "dnxcore50": { }, + "dnx451": { } + }, + "commands": { + "test": "xunit.runner.aspnet" + } +} diff --git a/test/Microsoft.AspNet.Routing.Tests/AttributeRouting/TreeRouterTest.cs b/test/Microsoft.AspNet.Routing.Tests/AttributeRouting/TreeRouterTest.cs new file mode 100644 index 00000000..8d9847a1 --- /dev/null +++ b/test/Microsoft.AspNet.Routing.Tests/AttributeRouting/TreeRouterTest.cs @@ -0,0 +1,2052 @@ +// 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. + +#if DNX451 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Routing.Template; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.OptionsModel; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Routing.Tree +{ + public class TreeRouterTest + { + [Theory] + [InlineData("template/5", "template/{parameter:int}")] + [InlineData("template/5", "template/{parameter}")] + [InlineData("template/5", "template/{*parameter:int}")] + [InlineData("template/5", "template/{*parameter}")] + [InlineData("template/{parameter:int}", "template/{parameter}")] + [InlineData("template/{parameter:int}", "template/{*parameter:int}")] + [InlineData("template/{parameter:int}", "template/{*parameter}")] + [InlineData("template/{parameter}", "template/{*parameter:int}")] + [InlineData("template/{parameter}", "template/{*parameter}")] + [InlineData("template/{*parameter:int}", "template/{*parameter}")] + public async Task AttributeRoute_RouteAsync_RespectsPrecedence( + string firstTemplate, + string secondTemplate) + { + // Arrange + var expectedRouteGroup = string.Format("{0}&&{1}", 0, firstTemplate); + + // We need to force the creation of a closure in order to avoid an issue with Moq and Roslyn. + var numberOfCalls = 0; + Action callBack = ctx => { ctx.IsHandled = true; numberOfCalls++; }; + + var next = new Mock(); + next.Setup(r => r.RouteAsync(It.IsAny())) + .Callback(callBack) + .Returns(Task.FromResult(true)) + .Verifiable(); + + var firstRoute = CreateMatchingEntry(next.Object, firstTemplate, order: 0); + var secondRoute = CreateMatchingEntry(next.Object, secondTemplate, order: 0); + + // We setup the route entries in reverse order of precedence to ensure that when we + // try to route the request, the route with a higher precedence gets tried first. + var matchingRoutes = new[] { secondRoute, firstRoute }; + + var linkGenerationEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateRouteContext("/template/5"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.Equal(expectedRouteGroup, context.RouteData.Values["test_route_group"]); + } + + [Theory] + [InlineData("template/5", "template/{parameter:int}")] + [InlineData("template/5", "template/{parameter}")] + [InlineData("template/5", "template/{*parameter:int}")] + [InlineData("template/5", "template/{*parameter}")] + [InlineData("template/{parameter:int}", "template/{parameter}")] + [InlineData("template/{parameter:int}", "template/{*parameter:int}")] + [InlineData("template/{parameter:int}", "template/{*parameter}")] + [InlineData("template/{parameter}", "template/{*parameter:int}")] + [InlineData("template/{parameter}", "template/{*parameter}")] + [InlineData("template/{*parameter:int}", "template/{*parameter}")] + public async Task AttributeRoute_RouteAsync_RespectsOrderOverPrecedence( + string firstTemplate, + string secondTemplate) + { + // Arrange + var expectedRouteGroup = string.Format("{0}&&{1}", 0, secondTemplate); + + // We need to force the creation of a closure in order to avoid an issue with Moq and Roslyn. + var numberOfCalls = 0; + Action callBack = ctx => { ctx.IsHandled = true; numberOfCalls++; }; + + var next = new Mock(); + next.Setup(r => r.RouteAsync(It.IsAny())) + .Callback(callBack) + .Returns(Task.FromResult(true)) + .Verifiable(); + + var firstRoute = CreateMatchingEntry(next.Object, firstTemplate, order: 1); + var secondRoute = CreateMatchingEntry(next.Object, secondTemplate, order: 0); + + // We setup the route entries with a lower relative order and higher relative precedence + // first to ensure that when we try to route the request, the route with the higher + // relative order gets tried first. + var matchingRoutes = new[] { firstRoute, secondRoute }; + + var linkGenerationEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateRouteContext("/template/5"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.Equal(expectedRouteGroup, context.RouteData.Values["test_route_group"]); + } + + [Theory] + [InlineData("template/5")] + [InlineData("template/{parameter:int}")] + [InlineData("template/{parameter}")] + [InlineData("template/{*parameter:int}")] + [InlineData("template/{*parameter}")] + public async Task AttributeRoute_RouteAsync_RespectsOrder(string template) + { + // Arrange + var expectedRouteGroup = string.Format("{0}&&{1}", 0, template); + + // We need to force the creation of a closure in order to avoid an issue with Moq and Roslyn. + var numberOfCalls = 0; + Action callBack = ctx => { ctx.IsHandled = true; numberOfCalls++; }; + + var next = new Mock(); + next.Setup(r => r.RouteAsync(It.IsAny())) + .Callback(callBack) + .Returns(Task.FromResult(true)) + .Verifiable(); + + var firstRoute = CreateMatchingEntry(next.Object, template, order: 1); + var secondRoute = CreateMatchingEntry(next.Object, template, order: 0); + + // We setup the route entries with a lower relative order first to ensure that when + // we try to route the request, the route with the higher relative order gets tried first. + var matchingRoutes = new[] { firstRoute, secondRoute }; + + var linkGenerationEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateRouteContext("/template/5"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.Equal(expectedRouteGroup, context.RouteData.Values["test_route_group"]); + } + + [Theory] + [InlineData("template/{first:int}", "template/{second:int}")] + [InlineData("template/{first}", "template/{second}")] + [InlineData("template/{*first:int}", "template/{*second:int}")] + [InlineData("template/{*first}", "template/{*second}")] + public async Task AttributeRoute_RouteAsync_EnsuresStableOrdering(string first, string second) + { + // Arrange + var expectedRouteGroup = string.Format("{0}&&{1}", 0, first); + + // We need to force the creation of a closure in order to avoid an issue with Moq and Roslyn. + var numberOfCalls = 0; + Action callBack = ctx => { ctx.IsHandled = true; numberOfCalls++; }; + + var next = new Mock(); + next.Setup(r => r.RouteAsync(It.IsAny())) + .Callback(callBack) + .Returns(Task.FromResult(true)) + .Verifiable(); + + var secondRouter = new Mock(MockBehavior.Strict); + + var firstRoute = CreateMatchingEntry(next.Object, first, order: 0); + var secondRoute = CreateMatchingEntry(next.Object, second, order: 0); + + // We setup the route entries with a lower relative template order first to ensure that when + // we try to route the request, the route with the higher template order gets tried first. + var matchingRoutes = new[] { secondRoute, firstRoute }; + + var linkGenerationEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateRouteContext("/template/5"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.Equal(expectedRouteGroup, context.RouteData.Values["test_route_group"]); + } + + [Theory] + [InlineData("template/{parameter:int}", "/template/5", true)] + [InlineData("template/{parameter:int?}", "/template/5", true)] + [InlineData("template/{parameter:int?}", "/template", true)] + [InlineData("template/{parameter:int?}", "/template/qwer", false)] + public async Task AttributeRoute_WithOptionalInlineConstraint( + string template, string request, bool expectedResult) + { + // Arrange + var expectedRouteGroup = string.Format("{0}&&{1}", 0, template); + + // We need to force the creation of a closure in order to avoid an issue with Moq and Roslyn. + var numberOfCalls = 0; + Action callBack = ctx => { ctx.IsHandled = true; numberOfCalls++; }; + + var next = new Mock(); + next.Setup(r => r.RouteAsync(It.IsAny())) + .Callback(callBack) + .Returns(Task.FromResult(true)) + .Verifiable(); + + var firstRoute = CreateMatchingEntry(next.Object, template, order: 0); + + // We setup the route entries in reverse order of precedence to ensure that when we + // try to route the request, the route with a higher precedence gets tried first. + var matchingRoutes = new[] { firstRoute }; + + var linkGenerationEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateRouteContext(request); + + // Act + await route.RouteAsync(context); + + // Assert + if (expectedResult) + { + Assert.True(context.IsHandled); + Assert.Equal(expectedRouteGroup, context.RouteData.Values["test_route_group"]); + } + else + { + Assert.False(context.IsHandled); + } + } + + [Theory] + [InlineData("moo/{p1}.{p2?}", "/moo/foo.bar", "foo", "bar", null)] + [InlineData("moo/{p1?}", "/moo/foo", "foo", null, null)] + [InlineData("moo/{p1?}", "/moo", null, null, null)] + [InlineData("moo/{p1}.{p2?}", "/moo/foo", "foo", null, null)] + [InlineData("moo/{p1}.{p2?}", "/moo/foo..bar", "foo.", "bar", null)] + [InlineData("moo/{p1}.{p2?}", "/moo/foo.moo.bar", "foo.moo", "bar", null)] + [InlineData("moo/{p1}.{p2}", "/moo/foo.bar", "foo", "bar", null)] + [InlineData("moo/foo.{p1}.{p2?}", "/moo/foo.moo.bar", "moo", "bar", null)] + [InlineData("moo/foo.{p1}.{p2?}", "/moo/foo.moo", "moo", null, null)] + [InlineData("moo/.{p2?}", "/moo/.foo", null, "foo", null)] + [InlineData("moo/{p1}.{p2?}", "/moo/....", "..", ".", null)] + [InlineData("moo/{p1}.{p2?}", "/moo/.bar", ".bar", null, null)] + [InlineData("moo/{p1}.{p2}.{p3?}", "/moo/foo.moo.bar", "foo", "moo", "bar")] + [InlineData("moo/{p1}.{p2}.{p3?}", "/moo/foo.moo", "foo", "moo", null)] + [InlineData("moo/{p1}.{p2}.{p3}.{p4?}", "/moo/foo.moo.bar", "foo", "moo", "bar")] + [InlineData("{p1}.{p2?}/{p3}", "/foo.moo/bar", "foo", "moo", "bar")] + [InlineData("{p1}.{p2?}/{p3}", "/foo/bar", "foo", null, "bar")] + [InlineData("{p1}.{p2?}/{p3}", "/.foo/bar", ".foo", null, "bar")] + public async Task AttributeRoute_WithOptionalCompositeParameter_Valid( + string template, + string request, + string p1, + string p2, + string p3) + { + // Arrange + var expectedRouteGroup = string.Format("{0}&&{1}", 0, template); + + // We need to force the creation of a closure in order to avoid an issue with Moq and Roslyn. + var numberOfCalls = 0; + Action callBack = ctx => { ctx.IsHandled = true; numberOfCalls++; }; + + var next = new Mock(); + next.Setup(r => r.RouteAsync(It.IsAny())) + .Callback(callBack) + .Returns(Task.FromResult(true)) + .Verifiable(); + + var firstRoute = CreateMatchingEntry(next.Object, template, order: 0); + + // We setup the route entries in reverse order of precedence to ensure that when we + // try to route the request, the route with a higher precedence gets tried first. + var matchingEntries = new[] { firstRoute }; + var linkGenerationEntries = Enumerable.Empty(); + var route = CreateAttributeRoute(next.Object, matchingEntries, linkGenerationEntries); + var context = CreateRouteContext(request); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.True(context.IsHandled); + if (p1 != null) + { + Assert.Equal(p1, context.RouteData.Values["p1"]); + } + if (p2 != null) + { + Assert.Equal(p2, context.RouteData.Values["p2"]); + } + if (p3 != null) + { + Assert.Equal(p3, context.RouteData.Values["p3"]); + } + } + + [Theory] + [InlineData("moo/{p1}.{p2?}", "/moo/foo.")] + [InlineData("moo/{p1}.{p2?}", "/moo/.")] + [InlineData("moo/{p1}.{p2}", "/foo.")] + [InlineData("moo/{p1}.{p2}", "/foo")] + [InlineData("moo/{p1}.{p2}.{p3?}", "/moo/foo.moo.")] + [InlineData("moo/foo.{p2}.{p3?}", "/moo/bar.foo.moo")] + [InlineData("moo/foo.{p2}.{p3?}", "/moo/kungfoo.moo.bar")] + [InlineData("moo/foo.{p2}.{p3?}", "/moo/kungfoo.moo")] + [InlineData("moo/{p1}.{p2}.{p3?}", "/moo/foo")] + [InlineData("{p1}.{p2?}/{p3}", "/foo./bar")] + [InlineData("moo/.{p2?}", "/moo/.")] + [InlineData("{p1}.{p2}/{p3}", "/.foo/bar")] + public async Task AttributeRoute_WithOptionalCompositeParameter_Invalid( + string template, + string request) + { + // Arrange + var expectedRouteGroup = string.Format("{0}&&{1}", 0, template); + + // We need to force the creation of a closure in order to avoid an issue with Moq and Roslyn. + var numberOfCalls = 0; + Action callBack = ctx => { ctx.IsHandled = true; numberOfCalls++; }; + + var next = new Mock(); + next.Setup(r => r.RouteAsync(It.IsAny())) + .Callback(callBack) + .Returns(Task.FromResult(true)) + .Verifiable(); + + var firstRoute = CreateMatchingEntry(next.Object, template, order: 0); + + // We setup the route entries in reverse order of precedence to ensure that when we + // try to route the request, the route with a higher precedence gets tried first. + var matchingEntries = new[] { firstRoute }; + var linkGenerationEntries = Enumerable.Empty(); + var route = CreateAttributeRoute(next.Object, matchingEntries, linkGenerationEntries); + + var context = CreateRouteContext(request); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.False(context.IsHandled); + } + + [Theory] + [InlineData("template", "{*url:alpha}", "/template?url=dingo&id=5")] + [InlineData("{*url:alpha}", "{*url}", "/dingo?id=5")] + [InlineData("{id}", "{*url}", "/5?url=dingo")] + [InlineData("{id}", "{*url:alpha}", "/5?url=dingo")] + [InlineData("{id:int}", "{id}", "/5?url=dingo")] + [InlineData("template/api/{*url}", "template/api", "/template/api/dingo?id=5")] + [InlineData("template/api", "template/{*url}", "/template/api?url=dingo&id=5")] + [InlineData("template/api", "template/api{id}location", "/template/api?url=dingo&id=5")] + [InlineData("template/api{id}location", "template/{id:int}", "/template/api5location?url=dingo")] + public void AttributeRoute_GenerateLink(string firstTemplate, string secondTemplate, string expectedPath) + { + // Arrange + var expectedGroup = CreateRouteGroup(0, firstTemplate); + + string selectedGroup = null; + Action callback = ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }; + + var values = new Dictionary + { + {"url", "dingo" }, + {"id", 5 } + }; + + var route = CreateAttributeRoute(callback, firstTemplate, secondTemplate); + var context = CreateVirtualPathContext( + values: values, + ambientValues: null); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString(expectedPath), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + Assert.Equal(expectedGroup, selectedGroup); + } + + [Fact] + public void AttributeRoute_GenerateLink_LongerTemplateWithDefaultIsMoreSpecific() + { + // Arrange + var firstTemplate = "template"; + var secondTemplate = "template/{parameter:int=1003}"; + + var expectedGroup = CreateRouteGroup(0, secondTemplate); + + string selectedGroup = null; + Action callback = ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }; + + var route = CreateAttributeRoute(callback, firstTemplate, secondTemplate); + var context = CreateVirtualPathContext( + values: null, + ambientValues: null); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + // The Binder binds to /template + Assert.Equal(new PathString($"/template"), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + // Even though the path was /template, the group generated from was /template/{paramter:int=1003} + Assert.Equal(expectedGroup, selectedGroup); + } + + [Theory] + [InlineData("template/{parameter:int=5}", "template", "/template/5")] + [InlineData("template/{parameter}", "template", "/template/5")] + [InlineData("template/{parameter}/{id}", "template/{parameter}", "/template/5/1234")] + public void AttributeRoute_GenerateLink_OrderingAgnostic( + string firstTemplate, + string secondTemplate, + string expectedPath) + { + var expectedGroup = CreateRouteGroup(0, firstTemplate); + + string selectedGroup = null; + Action callback = ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }; + + var route = CreateAttributeRoute(callback, firstTemplate, secondTemplate); + var parameter = 5; + var id = 1234; + var values = new Dictionary + { + { nameof(parameter) , parameter}, + { nameof(id), id } + }; + var context = CreateVirtualPathContext( + values: null, + ambientValues: values); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString(expectedPath), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + Assert.Equal(expectedGroup, selectedGroup); + } + + [Theory] + [InlineData("template", "template/{parameter}", "/template/5")] + [InlineData("template/{parameter}", "template/{parameter}/{id}", "/template/5/1234")] + [InlineData("template", "template/{parameter:int=5}", "/template/5")] + public void AttributeRoute_GenerateLink_UseAvailableVariables( + string firstTemplate, + string secondTemplate, + string expectedPath) + { + // Arrange + var expectedGroup = CreateRouteGroup(0, secondTemplate); + + string selectedGroup = null; + Action callback = ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }; + + var route = CreateAttributeRoute(callback, firstTemplate, secondTemplate); + var parameter = 5; + var id = 1234; + var values = new Dictionary + { + { nameof(parameter) , parameter}, + { nameof(id), id } + }; + var context = CreateVirtualPathContext( + values: null, + ambientValues: values); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString(expectedPath), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + Assert.Equal(expectedGroup, selectedGroup); + } + + [Theory] + [InlineData("template/5", "template/{parameter:int}")] + [InlineData("template/5", "template/{parameter}")] + [InlineData("template/5", "template/{*parameter:int}")] + [InlineData("template/5", "template/{*parameter}")] + [InlineData("template/{parameter:int}", "template/{parameter}")] + [InlineData("template/{parameter:int}", "template/{*parameter:int}")] + [InlineData("template/{parameter:int}", "template/{*parameter}")] + [InlineData("template/{parameter}", "template/{*parameter:int}")] + [InlineData("template/{parameter}", "template/{*parameter}")] + [InlineData("template/{*parameter:int}", "template/{*parameter}")] + public void AttributeRoute_GenerateLink_RespectsPrecedence(string firstTemplate, string secondTemplate) + { + // Arrange + var expectedGroup = CreateRouteGroup(0, firstTemplate); + + string selectedGroup = null; + + var next = new Mock(); + next.Setup(n => n.GetVirtualPath(It.IsAny())).Callback(ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }) + .Returns((VirtualPathData)null); + + var matchingRoutes = Enumerable.Empty(); + + var firstEntry = CreateGenerationEntry(firstTemplate, requiredValues: null); + var secondEntry = CreateGenerationEntry(secondTemplate, requiredValues: null, order: 0); + + // We setup the route entries in reverse order of precedence to ensure that when we + // try to generate a link, the route with a higher precedence gets tried first. + var linkGenerationEntries = new[] { secondEntry, firstEntry }; + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateVirtualPathContext(values: null, ambientValues: new { parameter = 5 }); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString("/template/5"), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + + Assert.Equal(expectedGroup, selectedGroup); + } + + [Theory] + [InlineData("template/{parameter:int}", "/template/5", 5)] + [InlineData("template/{parameter:int?}", "/template/5", 5)] + [InlineData("template/{parameter:int?}", "/template", null)] + [InlineData("template/{parameter:int?}", null, "asdf")] + [InlineData("template/{parameter:alpha?}", "/template/asdf", "asdf")] + [InlineData("template/{parameter:alpha?}", "/template", null)] + [InlineData("template/{parameter:int:range(1,20)?}", "/template", null)] + [InlineData("template/{parameter:int:range(1,20)?}", "/template/5", 5)] + [InlineData("template/{parameter:int:range(1,20)?}", null, 21)] + public void AttributeRoute_GenerateLink_OptionalInlineParameter + (string template, string expectedPath, object parameter) + { + // Arrange + var expectedGroup = CreateRouteGroup(0, template); + + string selectedGroup = null; + + var next = new Mock(); + next.Setup(n => n.GetVirtualPath(It.IsAny())).Callback(ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }) + .Returns((VirtualPathData)null); + + var matchingRoutes = Enumerable.Empty(); + + var entry = CreateGenerationEntry(template, requiredValues: null); + + var linkGenerationEntries = new[] { entry }; + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + VirtualPathContext context; + if (parameter != null) + { + context = CreateVirtualPathContext(values: null, ambientValues: new { parameter = parameter }); + } + else + { + context = CreateVirtualPathContext(values: null, ambientValues: null); + } + + // Act + var result = route.GetVirtualPath(context); + + // Assert + if (expectedPath == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Equal(new PathString(expectedPath), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + } + } + + [Theory] + [InlineData("template/5", "template/{parameter:int}")] + [InlineData("template/5", "template/{parameter}")] + [InlineData("template/5", "template/{*parameter:int}")] + [InlineData("template/5", "template/{*parameter}")] + [InlineData("template/{parameter:int}", "template/{parameter}")] + [InlineData("template/{parameter:int}", "template/{*parameter:int}")] + [InlineData("template/{parameter:int}", "template/{*parameter}")] + [InlineData("template/{parameter}", "template/{*parameter:int}")] + [InlineData("template/{parameter}", "template/{*parameter}")] + [InlineData("template/{*parameter:int}", "template/{*parameter}")] + public void AttributeRoute_GenerateLink_RespectsOrderOverPrecedence(string firstTemplate, string secondTemplate) + { + // Arrange + var selectedGroup = CreateRouteGroup(0, secondTemplate); + + string firstRouteGroupSelected = null; + var next = new Mock(); + next.Setup(n => n.GetVirtualPath(It.IsAny())).Callback(ctx => + { + firstRouteGroupSelected = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }) + .Returns((VirtualPathData)null); + + var matchingRoutes = Enumerable.Empty(); + + var firstRoute = CreateGenerationEntry(firstTemplate, requiredValues: null, order: 1); + var secondRoute = CreateGenerationEntry(secondTemplate, requiredValues: null, order: 0); + + // We setup the route entries with a lower relative order and higher relative precedence + // first to ensure that when we try to generate a link, the route with the higher + // relative order gets tried first. + var linkGenerationEntries = new[] { firstRoute, secondRoute }; + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateVirtualPathContext(null, ambientValues: new { parameter = 5 }); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString("/template/5"), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + + Assert.Equal(selectedGroup, firstRouteGroupSelected); + } + + [Theory] + [InlineData("template/5", "template/5")] + [InlineData("template/{first:int}", "template/{second:int}")] + [InlineData("template/{first}", "template/{second}")] + [InlineData("template/{*first:int}", "template/{*second:int}")] + [InlineData("template/{*first}", "template/{*second}")] + public void AttributeRoute_GenerateLink_RespectsOrder(string firstTemplate, string secondTemplate) + { + // Arrange + var expectedGroup = CreateRouteGroup(0, secondTemplate); + + var next = new Mock(); + string selectedGroup = null; + next.Setup(n => n.GetVirtualPath(It.IsAny())).Callback(ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }) + .Returns((VirtualPathData)null); + + var matchingRoutes = Enumerable.Empty(); + + var firstRoute = CreateGenerationEntry(firstTemplate, requiredValues: null, order: 1); + var secondRoute = CreateGenerationEntry(secondTemplate, requiredValues: null, order: 0); + + // We setup the route entries with a lower relative order first to ensure that when + // we try to generate a link, the route with the higher relative order gets tried first. + var linkGenerationEntries = new[] { firstRoute, secondRoute }; + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateVirtualPathContext(values: null, ambientValues: new { first = 5, second = 5 }); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString("/template/5"), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + + Assert.Equal(expectedGroup, selectedGroup); + } + + [Theory] + [InlineData("first/5", "second/5")] + [InlineData("first/{first:int}", "second/{second:int}")] + [InlineData("first/{first}", "second/{second}")] + [InlineData("first/{*first:int}", "second/{*second:int}")] + [InlineData("first/{*first}", "second/{*second}")] + public void AttributeRoute_GenerateLink_EnsuresStableOrder(string firstTemplate, string secondTemplate) + { + // Arrange + var expectedGroup = CreateRouteGroup(0, firstTemplate); + + var next = new Mock(); + string selectedGroup = null; + next.Setup(n => n.GetVirtualPath(It.IsAny())).Callback(ctx => + { + selectedGroup = (string)ctx.ProvidedValues[TreeRouter.RouteGroupKey]; + ctx.IsBound = true; + }) + .Returns((VirtualPathData)null); + + var matchingRoutes = Enumerable.Empty(); + + var firstRoute = CreateGenerationEntry(firstTemplate, requiredValues: null, order: 0); + var secondRoute = CreateGenerationEntry(secondTemplate, requiredValues: null, order: 0); + + // We setup the route entries with a lower relative template order first to ensure that when + // we try to generate a link, the route with the higher template order gets tried first. + var linkGenerationEntries = new[] { secondRoute, firstRoute }; + + var route = CreateAttributeRoute(next.Object, matchingRoutes, linkGenerationEntries); + + var context = CreateVirtualPathContext(values: null, ambientValues: new { first = 5, second = 5 }); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString("/first/5"), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + + Assert.Equal(expectedGroup, selectedGroup); + } + + public static IEnumerable NamedEntriesWithDifferentTemplates + { + get + { + var data = new TheoryData>(); + data.Add(new[] + { + CreateGenerationEntry("template", null, 0, "NamedEntry"), + CreateGenerationEntry("otherTemplate", null, 0, "NamedEntry"), + CreateGenerationEntry("anotherTemplate", null, 0, "NamedEntry") + }); + + // Default values for parameters are taken into account by comparing the templates. + data.Add(new[] + { + CreateGenerationEntry("template/{parameter=0}", null, 0, "NamedEntry"), + CreateGenerationEntry("template/{parameter=1}", null, 0, "NamedEntry"), + CreateGenerationEntry("template/{parameter=2}", null, 0, "NamedEntry") + }); + + // Names for entries are compared ignoring casing. + data.Add(new[] + { + CreateGenerationEntry("template/{*parameter:int=0}", null, 0, "NamedEntry"), + CreateGenerationEntry("template/{*parameter:int=1}", null, 0, "NAMEDENTRY"), + CreateGenerationEntry("template/{*parameter:int=2}", null, 0, "namedentry") + }); + return data; + } + } + + [Theory] + [MemberData(nameof(TreeRouterTest.NamedEntriesWithDifferentTemplates))] + public void AttributeRoute_CreateAttributeRoute_ThrowsIfDifferentEntriesHaveTheSameName( + IEnumerable namedEntries) + { + // Arrange + string expectedExceptionMessage = "Two or more routes named 'NamedEntry' have different templates." + + Environment.NewLine + + "Parameter name: linkGenerationEntries"; + + var next = new Mock().Object; + + // Act + var builder = new TreeRouteBuilder(next, NullLoggerFactory.Instance); + var exception = Assert.Throws( + "linkGenerationEntries", + () => + { + foreach (var entry in namedEntries) + { + builder.Add(entry); + } + + return builder.Build(version: 1); + }); + + Assert.Equal(expectedExceptionMessage, exception.Message, StringComparer.OrdinalIgnoreCase); + } + + public static IEnumerable NamedEntriesWithTheSameTemplate + { + get + { + var data = new TheoryData>(); + + data.Add(new[] + { + CreateGenerationEntry("template", null, 0, "NamedEntry"), + CreateGenerationEntry("template", null, 1, "NamedEntry"), + CreateGenerationEntry("template", null, 2, "NamedEntry") + }); + + // Templates are compared ignoring casing. + data.Add(new[] + { + CreateGenerationEntry("template", null, 0, "NamedEntry"), + CreateGenerationEntry("Template", null, 1, "NamedEntry"), + CreateGenerationEntry("TEMPLATE", null, 2, "NamedEntry") + }); + + data.Add(new[] + { + CreateGenerationEntry("template/{parameter=0}", null, 0, "NamedEntry"), + CreateGenerationEntry("template/{parameter=0}", null, 1, "NamedEntry"), + CreateGenerationEntry("template/{parameter=0}", null, 2, "NamedEntry") + }); + + return data; + } + } + + [Theory] + [MemberData(nameof(TreeRouterTest.NamedEntriesWithTheSameTemplate))] + public void AttributeRoute_GeneratesLink_ForMultipleNamedEntriesWithTheSameTemplate( + IEnumerable namedEntries) + { + // Arrange + var expectedLink = new PathString( + namedEntries.First().Template.Parameters.Any() ? "/template/5" : "/template"); + + var expectedGroup = "0&" + namedEntries.First().Template.TemplateText; + string selectedGroup = null; + var next = new Mock(); + next.Setup(s => s.GetVirtualPath(It.IsAny())) + .Callback(vpc => + { + vpc.IsBound = true; + selectedGroup = (string)vpc.ProvidedValues[TreeRouter.RouteGroupKey]; + }) + .Returns((VirtualPathData)null); + + var matchingEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingEntries, namedEntries); + + var ambientValues = namedEntries.First().Template.Parameters.Any() ? new { parameter = 5 } : null; + + var context = CreateVirtualPathContext(values: null, ambientValues: ambientValues, name: "NamedEntry"); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedLink, result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + + Assert.Equal(expectedGroup, selectedGroup); + } + + [Fact] + public void AttributeRoute_GenerateLink_WithName() + { + // Arrange + string selectedGroup = null; + var next = new Mock(); + next.Setup(s => s.GetVirtualPath(It.IsAny())) + .Callback(vpc => + { + vpc.IsBound = true; + selectedGroup = (string)vpc.ProvidedValues[TreeRouter.RouteGroupKey]; + }) + .Returns((VirtualPathData)null); + + var namedEntry = CreateGenerationEntry("named", requiredValues: null, order: 1, name: "NamedRoute"); + var unnamedEntry = CreateGenerationEntry("unnamed", requiredValues: null, order: 0); + + // The named route has a lower order which will ensure that we aren't trying the route as + // if it were an unnamed route. + var linkGenerationEntries = new[] { namedEntry, unnamedEntry }; + + var matchingEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingEntries, linkGenerationEntries); + + var context = CreateVirtualPathContext(values: null, ambientValues: null, name: "NamedRoute"); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString("/named"), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + + Assert.Equal("1&named", selectedGroup); + } + + [Fact] + public void AttributeRoute_DoesNotGenerateLink_IfThereIsNoRouteForAGivenName() + { + // Arrange + string selectedGroup = null; + var next = new Mock(); + next.Setup(s => s.GetVirtualPath(It.IsAny())) + .Callback(vpc => + { + vpc.IsBound = true; + selectedGroup = (string)vpc.ProvidedValues[TreeRouter.RouteGroupKey]; + }); + + var namedEntry = CreateGenerationEntry("named", requiredValues: null, order: 1, name: "NamedRoute"); + + // Add an unnamed entry to ensure we don't fall back to generating a link for an unnamed route. + var unnamedEntry = CreateGenerationEntry("unnamed", requiredValues: null, order: 0); + + // The named route has a lower order which will ensure that we aren't trying the route as + // if it were an unnamed route. + var linkGenerationEntries = new[] { namedEntry, unnamedEntry }; + + var matchingEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingEntries, linkGenerationEntries); + + var context = CreateVirtualPathContext(values: null, ambientValues: null, name: "NonExistingNamedRoute"); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("template/{parameter:int}", null)] + [InlineData("template/{parameter:int}", "NaN")] + [InlineData("template/{parameter}", null)] + [InlineData("template/{*parameter:int}", null)] + [InlineData("template/{*parameter:int}", "NaN")] + public void AttributeRoute_DoesNotGenerateLink_IfValuesDoNotMatchNamedEntry(string template, string value) + { + // Arrange + string selectedGroup = null; + var next = new Mock(); + next.Setup(s => s.GetVirtualPath(It.IsAny())) + .Callback(vpc => + { + vpc.IsBound = true; + selectedGroup = (string)vpc.ProvidedValues[TreeRouter.RouteGroupKey]; + }); + + var namedEntry = CreateGenerationEntry(template, requiredValues: null, order: 1, name: "NamedRoute"); + + // Add an unnamed entry to ensure we don't fall back to generating a link for an unnamed route. + var unnamedEntry = CreateGenerationEntry("unnamed", requiredValues: null, order: 0); + + // The named route has a lower order which will ensure that we aren't trying the route as + // if it were an unnamed route. + var linkGenerationEntries = new[] { namedEntry, unnamedEntry }; + + var matchingEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingEntries, linkGenerationEntries); + + var ambientValues = value == null ? null : new { parameter = value }; + + var context = CreateVirtualPathContext(values: null, ambientValues: ambientValues, name: "NamedRoute"); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData("template/{parameter:int}", "5")] + [InlineData("template/{parameter}", "5")] + [InlineData("template/{*parameter:int}", "5")] + [InlineData("template/{*parameter}", "5")] + public void AttributeRoute_GeneratesLink_IfValuesMatchNamedEntry(string template, string value) + { + // Arrange + string selectedGroup = null; + var next = new Mock(); + next.Setup(s => s.GetVirtualPath(It.IsAny())) + .Callback(vpc => + { + vpc.IsBound = true; + selectedGroup = (string)vpc.ProvidedValues[TreeRouter.RouteGroupKey]; + }) + .Returns((VirtualPathData)null); + + var namedEntry = CreateGenerationEntry(template, requiredValues: null, order: 1, name: "NamedRoute"); + + // Add an unnamed entry to ensure we don't fall back to generating a link for an unnamed route. + var unnamedEntry = CreateGenerationEntry("unnamed", requiredValues: null, order: 0); + + // The named route has a lower order which will ensure that we aren't trying the route as + // if it were an unnamed route. + var linkGenerationEntries = new[] { namedEntry, unnamedEntry }; + + var matchingEntries = Enumerable.Empty(); + + var route = CreateAttributeRoute(next.Object, matchingEntries, linkGenerationEntries); + + var ambientValues = value == null ? null : new { parameter = value }; + + var context = CreateVirtualPathContext(values: null, ambientValues: ambientValues, name: "NamedRoute"); + + // Act + var result = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(result); + Assert.Equal(new PathString("/template/5"), result.VirtualPath); + Assert.Same(route, result.Router); + Assert.Empty(result.DataTokens); + + Assert.Equal(string.Format("1&{0}", template), selectedGroup); + } + + [Fact] + public void AttributeRoute_GenerateLink_NoRequiredValues() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { }); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(new { }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store/Index"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [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" }, + { TreeRouter.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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/AwesomeCo/dosomething/Store/Index"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + + 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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [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 }, + { TreeRouter.RouteGroupKey, entry.RouteGroup }, + }; + + var next = new StubRouter(); + var route = CreateAttributeRoute(next, entry); + + var context = CreateVirtualPathContext(new { action = "Index", controller = "Store", id = 5 }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store/Index/5"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + + 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" }, + { TreeRouter.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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api/Store?id=5"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public void AttributeRoute_GenerateLink_ForwardsRouteGroup() + { + // Arrange + var entry = CreateGenerationEntry("api/Store", new { action = "Index", controller = "Store" }); + + var expectedValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { TreeRouter.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 pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api2/Blog"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [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( + TreeRouter.RouteGroupKey, + entry1.RouteGroup)); + }; + + var route = CreateAttributeRoute(next, entry1, entry2); + + var context = CreateVirtualPathContext(new { action = "Edit", controller = "Store" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/api2/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + + Assert.Equal(2, callCount); + } + + [Fact] + public void AttributeRoute_GenerateLink_ToArea() + { + // Arrange + var entry1 = CreateGenerationEntry("Help/Store", new { area = "Help", action = "Edit", controller = "Store" }); + entry1.GenerationPrecedence = 2; + + var entry2 = CreateGenerationEntry("Store", new { area = (string)null, action = "Edit", controller = "Store" }); + entry2.GenerationPrecedence = 1; + + var next = new StubRouter(); + + var route = CreateAttributeRoute(next, entry1, entry2); + + var context = CreateVirtualPathContext(new { area = "Help", action = "Edit", controller = "Store" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/Help/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public void AttributeRoute_GenerateLink_ToArea_PredecedenceReversed() + { + // Arrange + var entry1 = CreateGenerationEntry("Help/Store", new { area = "Help", action = "Edit", controller = "Store" }); + entry1.GenerationPrecedence = 1; + + var entry2 = CreateGenerationEntry("Store", new { area = (string)null, action = "Edit", controller = "Store" }); + entry2.GenerationPrecedence = 2; + + var next = new StubRouter(); + + var route = CreateAttributeRoute(next, entry1, entry2); + + var context = CreateVirtualPathContext(new { area = "Help", action = "Edit", controller = "Store" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/Help/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public void AttributeRoute_GenerateLink_ToArea_WithAmbientValues() + { + // Arrange + var entry1 = CreateGenerationEntry("Help/Store", new { area = "Help", action = "Edit", controller = "Store" }); + entry1.GenerationPrecedence = 2; + + var entry2 = CreateGenerationEntry("Store", new { area = (string)null, action = "Edit", controller = "Store" }); + entry2.GenerationPrecedence = 1; + + var next = new StubRouter(); + + var route = CreateAttributeRoute(next, entry1, entry2); + + var context = CreateVirtualPathContext( + values: new { action = "Edit", controller = "Store" }, + ambientValues: new { area = "Help" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/Help/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public void AttributeRoute_GenerateLink_OutOfArea_IgnoresAmbientValue() + { + // Arrange + var entry1 = CreateGenerationEntry("Help/Store", new { area = "Help", action = "Edit", controller = "Store" }); + entry1.GenerationPrecedence = 2; + + var entry2 = CreateGenerationEntry("Store", new { area = (string)null, action = "Edit", controller = "Store" }); + entry2.GenerationPrecedence = 1; + + var next = new StubRouter(); + + var route = CreateAttributeRoute(next, entry1, entry2); + + var context = CreateVirtualPathContext( + values: new { action = "Edit", controller = "Store" }, + ambientValues: new { area = "Blog" }); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString("/Store"), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + public static IEnumerable OptionalParamValues + { + get + { + return new object[][] + { + // defaults + // ambient values + // values + new object[] + { + "Test/{val1}/{val2}.{val3?}", + new {val1 = "someval1", val2 = "someval2", val3 = "someval3a"}, + new {val3 = "someval3v"}, + "/Test/someval1/someval2.someval3v", + }, + new object[] + { + "Test/{val1}/{val2}.{val3?}", + new {val3 = "someval3a"}, + new {val1 = "someval1", val2 = "someval2", val3 = "someval3v" }, + "/Test/someval1/someval2.someval3v", + }, + new object[] + { + "Test/{val1}/{val2}.{val3?}", + null, + new {val1 = "someval1", val2 = "someval2" }, + "/Test/someval1/someval2", + }, + new object[] + { + "Test/{val1}.{val2}.{val3}.{val4?}", + new {val1 = "someval1", val2 = "someval2" }, + new {val4 = "someval4", val3 = "someval3" }, + "/Test/someval1.someval2.someval3.someval4", + }, + new object[] + { + "Test/{val1}.{val2}.{val3}.{val4?}", + new {val1 = "someval1", val2 = "someval2" }, + new {val3 = "someval3" }, + "/Test/someval1.someval2.someval3", + }, + new object[] + { + "Test/.{val2?}", + null, + new {val2 = "someval2" }, + "/Test/.someval2", + }, + new object[] + { + "Test/.{val2?}", + null, + null, + "/Test/", + }, + new object[] + { + "Test/{val1}.{val2}", + new {val1 = "someval1", val2 = "someval2" }, + new {val3 = "someval3" }, + "/Test/someval1.someval2?val3=someval3", + }, + }; + } + } + + [Theory] + [MemberData("OptionalParamValues")] + public void AttributeRoute_GenerateLink_Match_WithOptionalParameters( + string template, + object ambientValues, + object values, + string expected) + { + // Arrange + var entry = CreateGenerationEntry(template, null); + var route = CreateAttributeRoute(entry); + + var context = CreateVirtualPathContext(values, ambientValues); + + // Act + var pathData = route.GetVirtualPath(context); + + // Assert + Assert.NotNull(pathData); + Assert.Equal(new PathString(expected), pathData.VirtualPath); + Assert.Same(route, pathData.Router); + Assert.Empty(pathData.DataTokens); + } + + [Fact] + public async Task AttributeRoute_CreatesNewRouteData() + { + // Arrange + RouteData nestedRouteData = null; + var next = new Mock(); + next + .Setup(r => r.RouteAsync(It.IsAny())) + .Callback((c) => + { + nestedRouteData = c.RouteData; + c.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var entry = CreateMatchingEntry(next.Object, "api/Store", order: 0); + var route = CreateAttributeRoute(next.Object, entry); + + var context = CreateRouteContext("/api/Store"); + + var originalRouteData = context.RouteData; + originalRouteData.Values.Add("action", "Index"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.NotSame(originalRouteData, context.RouteData); + Assert.NotSame(originalRouteData, nestedRouteData); + Assert.Same(nestedRouteData, context.RouteData); + + // The new routedata is a copy + Assert.Equal("Index", context.RouteData.Values["action"]); + Assert.Single(context.RouteData.Values, kvp => kvp.Key == "test_route_group"); + + Assert.Equal(1, context.RouteData.Routers.Count); + Assert.Equal(next.Object.GetType(), context.RouteData.Routers[0].GetType()); + } + + [Fact] + public async Task AttributeRoute_ReplacesExistingRouteValues_IfNotNull() + { + // Arrange + var router = new Mock(); + router + .Setup(r => r.RouteAsync(It.IsAny())) + .Callback((c) => + { + c.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var entry = CreateMatchingEntry(router.Object, "Foo/{*path}", order: 0); + var route = CreateAttributeRoute(router.Object, entry); + + var context = CreateRouteContext("/Foo/Bar"); + + var originalRouteData = context.RouteData; + originalRouteData.Values.Add("path", "default"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.Equal("Bar", context.RouteData.Values["path"]); + } + + [Fact] + public async Task AttributeRoute_DoesNotReplaceExistingRouteValues_IfNull() + { + // Arrange + var router = new Mock(); + router + .Setup(r => r.RouteAsync(It.IsAny())) + .Callback((c) => + { + c.IsHandled = true; + }) + .Returns(Task.FromResult(true)); + + var entry = CreateMatchingEntry(router.Object, "Foo/{*path}", order: 0); + var route = CreateAttributeRoute(router.Object, entry); + + var context = CreateRouteContext("/Foo/"); + + var originalRouteData = context.RouteData; + originalRouteData.Values.Add("path", "default"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.Equal("default", context.RouteData.Values["path"]); + } + + [Fact] + public async Task AttributeRoute_CreatesNewRouteData_ResetsWhenNotMatched() + { + // Arrange + RouteData nestedRouteData = null; + var next = new Mock(); + next + .Setup(r => r.RouteAsync(It.IsAny())) + .Callback((c) => + { + nestedRouteData = c.RouteData; + c.IsHandled = false; + }) + .Returns(Task.FromResult(true)); + + var entry = CreateMatchingEntry(next.Object, "api/Store", order: 0); + var route = CreateAttributeRoute(next.Object, entry); + + var context = CreateRouteContext("/api/Store"); + + var originalRouteData = context.RouteData; + originalRouteData.Values.Add("action", "Index"); + + // Act + await route.RouteAsync(context); + + // Assert + Assert.Same(originalRouteData, context.RouteData); + Assert.NotSame(originalRouteData, nestedRouteData); + Assert.NotSame(nestedRouteData, context.RouteData); + + // The new routedata is a copy + Assert.Equal("Index", context.RouteData.Values["action"]); + Assert.Equal("Index", nestedRouteData.Values["action"]); + Assert.DoesNotContain(context.RouteData.Values, kvp => kvp.Key == "test_route_group"); + Assert.Single(nestedRouteData.Values, kvp => kvp.Key == "test_route_group"); + + Assert.Empty(context.RouteData.Routers); + + Assert.Equal(1, nestedRouteData.Routers.Count); + Assert.Equal(next.Object.GetType(), nestedRouteData.Routers[0].GetType()); + } + + [Fact] + public async Task AttributeRoute_CreatesNewRouteData_ResetsWhenThrows() + { + // Arrange + RouteData nestedRouteData = null; + var next = new Mock(); + next + .Setup(r => r.RouteAsync(It.IsAny())) + .Callback((c) => + { + nestedRouteData = c.RouteData; + c.IsHandled = false; + }) + .Throws(new Exception()); + + var entry = CreateMatchingEntry(next.Object, "api/Store", order: 0); + var route = CreateAttributeRoute(next.Object, entry); + + var context = CreateRouteContext("/api/Store"); + + var originalRouteData = context.RouteData; + originalRouteData.Values.Add("action", "Index"); + + // Act + await Assert.ThrowsAsync(() => route.RouteAsync(context)); + + // Assert + Assert.Same(originalRouteData, context.RouteData); + Assert.NotSame(originalRouteData, nestedRouteData); + Assert.NotSame(nestedRouteData, context.RouteData); + + // The new routedata is a copy + Assert.Equal("Index", context.RouteData.Values["action"]); + Assert.Equal("Index", nestedRouteData.Values["action"]); + Assert.DoesNotContain(context.RouteData.Values, kvp => kvp.Key == "test_route_group"); + Assert.Single(nestedRouteData.Values, kvp => kvp.Key == "test_route_group"); + + Assert.Empty(context.RouteData.Routers); + + Assert.Equal(1, nestedRouteData.Routers.Count); + Assert.Equal(next.Object.GetType(), nestedRouteData.Routers[0].GetType()); + } + + private static RouteContext CreateRouteContext(string requestPath) + { + var request = new Mock(MockBehavior.Strict); + request.SetupGet(r => r.Path).Returns(new PathString(requestPath)); + + var context = new Mock(MockBehavior.Strict); + context.Setup(m => m.RequestServices.GetService(typeof(ILoggerFactory))) + .Returns(NullLoggerFactory.Instance); + + context.SetupGet(c => c.Request).Returns(request.Object); + + return new RouteContext(context.Object); + } + + private static VirtualPathContext CreateVirtualPathContext( + object values, + object ambientValues = null, + string name = null) + { + var mockHttpContext = new Mock(); + mockHttpContext.Setup(h => h.RequestServices.GetService(typeof(ILoggerFactory))) + .Returns(NullLoggerFactory.Instance); + + return new VirtualPathContext( + mockHttpContext.Object, + new RouteValueDictionary(ambientValues), + new RouteValueDictionary(values), + name); + } + + private static TreeRouteMatchingEntry CreateMatchingEntry(IRouter router, string template, int order) + { + var routeGroup = string.Format("{0}&&{1}", order, template); + var entry = new TreeRouteMatchingEntry(); + entry.Target = router; + entry.RouteTemplate = TemplateParser.Parse(template); + var parsedRouteTemplate = TemplateParser.Parse(template); + entry.TemplateMatcher = new TemplateMatcher( + parsedRouteTemplate, + new RouteValueDictionary(new { test_route_group = routeGroup })); + entry.Precedence = RoutePrecedence.ComputeMatched(parsedRouteTemplate); + entry.Order = order; + entry.Constraints = GetRouteConstriants(CreateConstraintResolver(), template, parsedRouteTemplate); + return entry; + } + + private static TreeRouteLinkGenerationEntry CreateGenerationEntry( + string template, + object requiredValues, + int order = 0, + string name = null) + { + var constraintResolver = CreateConstraintResolver(); + + var entry = new TreeRouteLinkGenerationEntry(); + entry.Template = TemplateParser.Parse(template); + + var defaults = entry.Template.Parameters + .Where(p => p.DefaultValue != null) + .ToDictionary(p => p.Name, p => p.DefaultValue); + + var constraintBuilder = new RouteConstraintBuilder(CreateConstraintResolver(), template); + foreach (var parameter in entry.Template.Parameters) + { + if (parameter.InlineConstraints != null) + { + if (parameter.IsOptional) + { + constraintBuilder.SetOptional(parameter.Name); + } + + foreach (var constraint in parameter.InlineConstraints) + { + constraintBuilder.AddResolvedConstraint(parameter.Name, constraint.Constraint); + } + } + } + + var constraints = constraintBuilder.Build(); + + entry.Constraints = constraints; + entry.Defaults = defaults; + entry.Binder = new TemplateBinder(entry.Template, defaults); + entry.Order = order; + entry.GenerationPrecedence = RoutePrecedence.ComputeGenerated(entry.Template); + entry.RequiredLinkValues = new RouteValueDictionary(requiredValues); + entry.RouteGroup = CreateRouteGroup(order, template); + entry.Name = name; + return entry; + } + + private TreeRouteMatchingEntry CreateMatchingEntry(string template) + { + var mockConstraint = new Mock(); + mockConstraint.Setup(c => c.Match( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(true); + + var mockConstraintResolver = new Mock(); + mockConstraintResolver.Setup(r => r.ResolveConstraint( + It.IsAny())) + .Returns(mockConstraint.Object); + + var entry = new TreeRouteMatchingEntry(); + entry.Target = new StubRouter(); + entry.RouteTemplate = TemplateParser.Parse(template); + + return entry; + } + + private static string CreateRouteGroup(int order, string template) + { + return string.Format("{0}&{1}", order, template); + } + + private static DefaultInlineConstraintResolver CreateConstraintResolver() + { + var options = new RouteOptions(); + var optionsMock = new Mock>(); + optionsMock.SetupGet(o => o.Value).Returns(options); + + return new DefaultInlineConstraintResolver(optionsMock.Object); + } + + private static TreeRouter CreateAttributeRoute(TreeRouteLinkGenerationEntry entry) + { + return CreateAttributeRoute(new StubRouter(), entry); + } + + private static TreeRouter CreateAttributeRoute(IRouter next, TreeRouteLinkGenerationEntry entry) + { + return CreateAttributeRoute(next, new[] { entry }); + } + + private static TreeRouter CreateAttributeRoute(params TreeRouteLinkGenerationEntry[] entries) + { + return CreateAttributeRoute(new StubRouter(), entries); + } + + private static TreeRouter CreateAttributeRoute(IRouter next, params TreeRouteLinkGenerationEntry[] entries) + { + return CreateAttributeRoute( + next, + Enumerable.Empty(), + entries); + } + + private static TreeRouter CreateAttributeRoute(IRouter next, params TreeRouteMatchingEntry[] entries) + { + return CreateAttributeRoute( + next, + entries, + Enumerable.Empty()); + } + + private static TreeRouter CreateAttributeRoute( + IRouter next, + IEnumerable matchingEntries, + IEnumerable generationEntries) + { + var builder = new TreeRouteBuilder(next, NullLoggerFactory.Instance); + + foreach (var entry in matchingEntries) + { + builder.Add(entry); + } + + foreach (var entry in generationEntries) + { + builder.Add(entry); + } + + return builder.Build(version: 1); + } + + private static TreeRouter CreateAttributeRoute( + Action virtualPathCallback, + string firstTemplate, + string secondTemplate) + { + var next = new Mock(); + next.Setup(n => n.GetVirtualPath(It.IsAny())).Callback(virtualPathCallback) + .Returns((VirtualPathData)null); + + var matchingRoutes = Enumerable.Empty(); + var firstEntry = CreateGenerationEntry(firstTemplate, requiredValues: null); + var secondEntry = CreateGenerationEntry(secondTemplate, requiredValues: null); + + return CreateAttributeRoute( + next.Object, + matchingRoutes, + new[] { secondEntry, firstEntry }); + } + + private static TreeRouter CreateRoutingAttributeRoute( + ILoggerFactory loggerFactory = null, + params TreeRouteMatchingEntry[] entries) + { + loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + + var builder = new TreeRouteBuilder(new StubRouter(), loggerFactory); + + foreach (var entry in entries) + { + builder.Add(entry); + } + + return builder.Build(version: 1); + } + + private static IReadOnlyDictionary GetRouteConstriants( + IInlineConstraintResolver inlineConstraintResolver, + string template, + RouteTemplate parsedRouteTemplate) + { + var constraintBuilder = new RouteConstraintBuilder(inlineConstraintResolver, template); + foreach (var parameter in parsedRouteTemplate.Parameters) + { + if (parameter.InlineConstraints != null) + { + if (parameter.IsOptional) + { + constraintBuilder.SetOptional(parameter.Name); + } + + foreach (var constraint in parameter.InlineConstraints) + { + constraintBuilder.AddResolvedConstraint(parameter.Name, constraint.Constraint); + } + } + } + + return constraintBuilder.Build(); + } + 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 VirtualPathData 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(true); + } + } + } +} + +#endif \ No newline at end of file diff --git a/test/Microsoft.AspNet.Routing.Tests/Internal/LinkGenerationDecisionTreeTest.cs b/test/Microsoft.AspNet.Routing.Tests/Internal/LinkGenerationDecisionTreeTest.cs new file mode 100644 index 00000000..b4d6b288 --- /dev/null +++ b/test/Microsoft.AspNet.Routing.Tests/Internal/LinkGenerationDecisionTreeTest.cs @@ -0,0 +1,338 @@ +// 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.AspNet.Http.Internal; +using Microsoft.AspNet.Routing.Template; +using Microsoft.AspNet.Routing.Tree; +using Xunit; + +namespace Microsoft.AspNet.Routing.Internal.Routing +{ + public class LinkGenerationDecisionTreeTest + { + [Fact] + public void SelectSingleEntry_NoCriteria() + { + // Arrange + var entries = new List(); + + var entry = CreateEntry(new { }); + entries.Add(entry); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(new { }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + Assert.Same(entry, Assert.Single(matches).Entry); + } + + [Fact] + public void SelectSingleEntry_MultipleCriteria() + { + // Arrange + var entries = new List(); + + var entry = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + Assert.Same(entry, Assert.Single(matches).Entry); + } + + [Fact] + public void SelectSingleEntry_MultipleCriteria_AmbientValues() + { + // Arrange + var entries = new List(); + + var entry = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(values: null, ambientValues: new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + var match = Assert.Single(matches); + Assert.Same(entry, match.Entry); + Assert.False(match.IsFallbackMatch); + } + + [Fact] + public void SelectSingleEntry_MultipleCriteria_Replaced() + { + // Arrange + var entries = new List(); + + var entry = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext( + values: new { action = "Buy" }, + ambientValues: new { controller = "Store", action = "Cart" }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + var match = Assert.Single(matches); + Assert.Same(entry, match.Entry); + Assert.False(match.IsFallbackMatch); + } + + [Fact] + public void SelectSingleEntry_MultipleCriteria_AmbientValue_Ignored() + { + // Arrange + var entries = new List(); + + var entry = CreateEntry(new { controller = "Store", action = (string)null }); + entries.Add(entry); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext( + values: new { controller = "Store" }, + ambientValues: new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + var match = Assert.Single(matches); + Assert.Same(entry, match.Entry); + Assert.True(match.IsFallbackMatch); + } + + [Fact] + public void SelectSingleEntry_MultipleCriteria_NoMatch() + { + // Arrange + var entries = new List(); + + var entry = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(new { controller = "Store", action = "AddToCart" }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + Assert.Empty(matches); + } + + [Fact] + public void SelectSingleEntry_MultipleCriteria_AmbientValue_NoMatch() + { + // Arrange + var entries = new List(); + + var entry = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext( + values: new { controller = "Store" }, + ambientValues: new { controller = "Store", action = "Cart" }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + Assert.Empty(matches); + } + + [Fact] + public void SelectMultipleEntries_OneDoesntMatch() + { + // Arrange + var entries = new List(); + + var entry1 = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry1); + + var entry2 = CreateEntry(new { controller = "Store", action = "Cart" }); + entries.Add(entry2); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext( + values: new { controller = "Store" }, + ambientValues: new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context); + + // Assert + Assert.Same(entry1, Assert.Single(matches).Entry); + } + + [Fact] + public void SelectMultipleEntries_BothMatch_CriteriaSubset() + { + // Arrange + var entries = new List(); + + var entry1 = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry1); + + var entry2 = CreateEntry(new { controller = "Store" }); + entry2.Order = 1; + entries.Add(entry2); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext( + values: new { controller = "Store" }, + ambientValues: new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context).Select(m => m.Entry).ToList(); + + // Assert + Assert.Equal(entries, matches); + } + + [Fact] + public void SelectMultipleEntries_BothMatch_NonOverlappingCriteria() + { + // Arrange + var entries = new List(); + + var entry1 = CreateEntry(new { controller = "Store", action = "Buy" }); + entries.Add(entry1); + + var entry2 = CreateEntry(new { slug = "1234" }); + entry2.Order = 1; + entries.Add(entry2); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(new { controller = "Store", action = "Buy", slug = "1234" }); + + // Act + var matches = tree.GetMatches(context).Select(m => m.Entry).ToList(); + + // Assert + Assert.Equal(entries, matches); + } + + // Precedence is ignored for sorting because they have different order + [Fact] + public void SelectMultipleEntries_BothMatch_OrderedByOrder() + { + // Arrange + var entries = new List(); + + var entry1 = CreateEntry(new { controller = "Store", action = "Buy" }); + entry1.GenerationPrecedence = 0; + entries.Add(entry1); + + var entry2 = CreateEntry(new { controller = "Store", action = "Buy" }); + entry2.Order = 1; + entry2.GenerationPrecedence = 1; + entries.Add(entry2); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context).Select(m => m.Entry).ToList(); + + // Assert + Assert.Equal(entries, matches); + } + + // Precedence is used for sorting because they have the same order + [Fact] + public void SelectMultipleEntries_BothMatch_OrderedByPrecedence() + { + // Arrange + var entries = new List(); + + var entry1 = CreateEntry(new { controller = "Store", action = "Buy" }); + entry1.GenerationPrecedence = 1; + entries.Add(entry1); + + var entry2 = CreateEntry(new { controller = "Store", action = "Buy" }); + entry2.GenerationPrecedence = 0; + entries.Add(entry2); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context).Select(m => m.Entry).ToList(); + + // Assert + Assert.Equal(entries, matches); + } + + // Template is used for sorting because they have the same order + [Fact] + public void SelectMultipleEntries_BothMatch_OrderedByTemplate() + { + // Arrange + var entries = new List(); + + var entry1 = CreateEntry(new { controller = "Store", action = "Buy" }); + entry1.Template = TemplateParser.Parse("a"); + entries.Add(entry1); + + var entry2 = CreateEntry(new { controller = "Store", action = "Buy" }); + entry2.Template = TemplateParser.Parse("b"); + entries.Add(entry2); + + var tree = new LinkGenerationDecisionTree(entries); + + var context = CreateContext(new { controller = "Store", action = "Buy" }); + + // Act + var matches = tree.GetMatches(context).Select(m => m.Entry).ToList(); + + // Assert + Assert.Equal(entries, matches); + } + + private TreeRouteLinkGenerationEntry CreateEntry(object requiredValues) + { + var entry = new TreeRouteLinkGenerationEntry(); + entry.RequiredLinkValues = new RouteValueDictionary(requiredValues); + return entry; + } + + private VirtualPathContext CreateContext(object values, object ambientValues = null) + { + var context = new VirtualPathContext( + new DefaultHttpContext(), + new RouteValueDictionary(ambientValues), + new RouteValueDictionary(values)); + + return context; + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Routing.Tests/Template/RoutePrecedenceTests.cs b/test/Microsoft.AspNet.Routing.Tests/Template/RoutePrecedenceTests.cs new file mode 100644 index 00000000..0a34b3c2 --- /dev/null +++ b/test/Microsoft.AspNet.Routing.Tests/Template/RoutePrecedenceTests.cs @@ -0,0 +1,124 @@ +// 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. + +#if DNX451 +using System; +using Microsoft.AspNet.Routing; +using Microsoft.Extensions.OptionsModel; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Routing.Template +{ + public class RoutePrecedenceTests + { + [Theory] + [InlineData("Employees/{id}", "Employees/{employeeId}")] + [InlineData("abc", "def")] + [InlineData("{x:alpha}", "{x:int}")] + public void ComputeMatched_IsEqual(string xTemplate, string yTemplate) + { + // Arrange & Act + var xPrededence = ComputeMatched(xTemplate); + var yPrededence = ComputeMatched(yTemplate); + + // Assert + Assert.Equal(xPrededence, yPrededence); + } + + [Theory] + [InlineData("Employees/{id}", "Employees/{employeeId}")] + [InlineData("abc", "def")] + [InlineData("{x:alpha}", "{x:int}")] + public void ComputeGenerated_IsEqual(string xTemplate, string yTemplate) + { + // Arrange & Act + var xPrededence = ComputeGenerated(xTemplate); + var yPrededence = ComputeGenerated(yTemplate); + + // Assert + Assert.Equal(xPrededence, yPrededence); + } + + [Theory] + [InlineData("abc", "a{x}")] + [InlineData("abc", "{x}c")] + [InlineData("abc", "{x:int}")] + [InlineData("abc", "{x}")] + [InlineData("abc", "{*x}")] + [InlineData("{x:int}", "{x}")] + [InlineData("{x:int}", "{*x}")] + [InlineData("a{x}", "{x}")] + [InlineData("{x}c", "{x}")] + [InlineData("a{x}", "{*x}")] + [InlineData("{x}c", "{*x}")] + [InlineData("{x}", "{*x}")] + [InlineData("{*x:maxlength(10)}", "{*x}")] + [InlineData("abc/def", "abc/{x:int}")] + [InlineData("abc/def", "abc/{x}")] + [InlineData("abc/def", "abc/{*x}")] + [InlineData("abc/{x:int}", "abc/{x}")] + [InlineData("abc/{x:int}", "abc/{*x}")] + [InlineData("abc/{x}", "abc/{*x}")] + [InlineData("{x}/{y:int}", "{x}/{y}")] + public void ComputeMatched_IsLessThan(string xTemplate, string yTemplate) + { + // Arrange & Act + var xPrededence = ComputeMatched(xTemplate); + var yPrededence = ComputeMatched(yTemplate); + + // Assert + Assert.True(xPrededence < yPrededence); + } + + [Theory] + [InlineData("abc", "a{x}")] + [InlineData("abc", "{x}c")] + [InlineData("abc", "{x:int}")] + [InlineData("abc", "{x}")] + [InlineData("abc", "{*x}")] + [InlineData("{x:int}", "{x}")] + [InlineData("{x:int}", "{*x}")] + [InlineData("a{x}", "{x}")] + [InlineData("{x}c", "{x}")] + [InlineData("a{x}", "{*x}")] + [InlineData("{x}c", "{*x}")] + [InlineData("{x}", "{*x}")] + [InlineData("{*x:maxlength(10)}", "{*x}")] + [InlineData("abc/def", "abc/{x:int}")] + [InlineData("abc/def", "abc/{x}")] + [InlineData("abc/def", "abc/{*x}")] + [InlineData("abc/{x:int}", "abc/{x}")] + [InlineData("abc/{x:int}", "abc/{*x}")] + [InlineData("abc/{x}", "abc/{*x}")] + [InlineData("{x}/{y:int}", "{x}/{y}")] + public void ComputeGenerated_IsGreaterThan(string xTemplate, string yTemplate) + { + // Arrange & Act + var xPrecedence = ComputeGenerated(xTemplate); + var yPrecedence = ComputeGenerated(yTemplate); + + // Assert + Assert.True(xPrecedence > yPrecedence); + } + + private static decimal ComputeMatched(string template) + { + return Compute(template, RoutePrecedence.ComputeMatched); + } + private static decimal ComputeGenerated(string template) + { + return Compute(template, RoutePrecedence.ComputeGenerated); + } + + private static decimal Compute(string template, Func func) + { + var options = new Mock>(); + options.SetupGet(o => o.Value).Returns(new RouteOptions()); + + var parsed = TemplateParser.Parse(template); + return func(parsed); + } + } +} +#endif diff --git a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateParserTests.cs b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateParserTests.cs index 5e466e9f..405335f2 100644 --- a/test/Microsoft.AspNet.Routing.Tests/Template/TemplateParserTests.cs +++ b/test/Microsoft.AspNet.Routing.Tests/Template/TemplateParserTests.cs @@ -830,7 +830,7 @@ public bool Equals(RouteTemplate x, RouteTemplate y) } else { - if (!string.Equals(x.Template, y.Template, StringComparison.Ordinal)) + if (!string.Equals(x.TemplateText, y.TemplateText, StringComparison.Ordinal)) { return false; }