diff --git a/src/Microsoft.AspNetCore.Dispatcher/Patterns/RoutePatternMatcher.cs b/src/Microsoft.AspNetCore.Dispatcher/Patterns/RoutePatternMatcher.cs new file mode 100644 index 0000000000..2a5c431e57 --- /dev/null +++ b/src/Microsoft.AspNetCore.Dispatcher/Patterns/RoutePatternMatcher.cs @@ -0,0 +1,473 @@ +// 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.Diagnostics; +using Microsoft.AspNetCore.Dispatcher.Internal; +using Microsoft.AspNetCore.Dispatcher.Patterns; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Dispatcher +{ + public class RoutePatternMatcher + { + private const string SeparatorString = "/"; + private const char SeparatorChar = '/'; + + // Perf: This is a cache to avoid looking things up in 'Defaults' each request. + private readonly bool[] _hasDefaultValue; + private readonly object[] _defaultValues; + + private static readonly char[] Delimiters = new char[] { SeparatorChar }; + + public RoutePatternMatcher( + RoutePattern routePattern, + DispatcherValueCollection defaults) + { + if (routePattern == null) + { + throw new ArgumentNullException(nameof(routePattern)); + } + + RoutePattern = routePattern; + Defaults = defaults ?? new DispatcherValueCollection(); + + // Perf: cache the default value for each parameter (other than complex segments). + _hasDefaultValue = new bool[RoutePattern.PathSegments.Count]; + _defaultValues = new object[RoutePattern.PathSegments.Count]; + + for (var i = 0; i < RoutePattern.PathSegments.Count; i++) + { + var segment = RoutePattern.PathSegments[i]; + if (!segment.IsSimple) + { + continue; + } + + var part = segment.Parts[0]; + if (!part.IsParameter) + { + continue; + } + + object value; + var parameter = (RoutePatternParameter)part; + if (Defaults.TryGetValue(parameter.Name, out value)) + { + _hasDefaultValue[i] = true; + _defaultValues[i] = value; + } + } + } + + public DispatcherValueCollection Defaults { get; } + + public RoutePattern RoutePattern { get; } + + public bool TryMatch(PathString path, DispatcherValueCollection values) + { + if (values == null) + { + throw new ArgumentNullException(nameof(values)); + } + + var i = 0; + var pathTokenizer = new PathTokenizer(path); + + // Perf: We do a traversal of the request-segments + route-segments twice. + // + // For most segment-types, we only really need to any work on one of the two passes. + // + // On the first pass, we're just looking to see if there's anything that would disqualify us from matching. + // The most common case would be a literal segment that doesn't match. + // + // On the second pass, we're almost certainly going to match the URL, so go ahead and allocate the 'values' + // and start capturing strings. + foreach (var stringSegment in pathTokenizer) + { + if (stringSegment.Length == 0) + { + return false; + } + + var pathSegment = i >= RoutePattern.PathSegments.Count ? null : RoutePattern.PathSegments[i]; + if (pathSegment == null && stringSegment.Length > 0) + { + // If pathSegment is null, then we're out of route segments. All we can match is the empty + // string. + return false; + } + else if (pathSegment.IsSimple && pathSegment.Parts[0] is RoutePatternParameter parameter && parameter.IsCatchAll) + { + // Nothing to validate for a catch-all - it can match any string, including the empty string. + // + // Also, a catch-all has to be the last part, so we're done. + break; + } + if (!TryMatchLiterals(i++, stringSegment, pathSegment)) + { + return false; + } + } + + for (; i < RoutePattern.PathSegments.Count; i++) + { + // We've matched the request path so far, but still have remaining route segments. These need + // to be all single-part parameter segments with default values or else they won't match. + var pathSegment = RoutePattern.PathSegments[i]; + Debug.Assert(pathSegment != null); + + if (!pathSegment.IsSimple) + { + // If the segment is a complex segment, it MUST contain literals, and we've parsed the full + // path so far, so it can't match. + return false; + } + + var part = pathSegment.Parts[0]; + if (part.IsLiteral || part.IsSeparator) + { + // If the segment is a simple literal - which need the URL to provide a value, so we don't match. + return false; + } + + var parameter = (RoutePatternParameter)part; + if (parameter.IsCatchAll) + { + // Nothing to validate for a catch-all - it can match any string, including the empty string. + // + // Also, a catch-all has to be the last part, so we're done. + break; + } + + // If we get here, this is a simple segment with a parameter. We need it to be optional, or for the + // defaults to have a value. + if (!_hasDefaultValue[i] && !parameter.IsOptional) + { + // There's no default for this (non-optional) parameter so it can't match. + return false; + } + } + + // At this point we've very likely got a match, so start capturing values for real. + i = 0; + foreach (var requestSegment in pathTokenizer) + { + var pathSegment = RoutePattern.PathSegments[i++]; + if (SavePathSegmentsAsValues(i, values, requestSegment, pathSegment)) + { + break; + } + if (!pathSegment.IsSimple) + { + if (!MatchComplexSegment(pathSegment, requestSegment.ToString(), Defaults, values)) + { + return false; + } + } + } + + for (; i < RoutePattern.PathSegments.Count; i++) + { + // We've matched the request path so far, but still have remaining route segments. We already know these + // are simple parameters that either have a default, or don't need to produce a value. + var pathSegment = RoutePattern.PathSegments[i]; + Debug.Assert(pathSegment != null); + Debug.Assert(pathSegment.IsSimple); + + var part = pathSegment.Parts[0]; + Debug.Assert(part.IsParameter); + + // It's ok for a catch-all to produce a null value + if (part is RoutePatternParameter parameter && (parameter.IsCatchAll || _hasDefaultValue[i])) + { + // Don't replace an existing value with a null. + var defaultValue = _defaultValues[i]; + if (defaultValue != null || !values.ContainsKey(parameter.Name)) + { + values[parameter.Name] = defaultValue; + } + } + } + + // Copy all remaining default values to the route data + foreach (var kvp in Defaults) + { + if (!values.ContainsKey(kvp.Key)) + { + values.Add(kvp.Key, kvp.Value); + } + } + + return true; + } + + private bool TryMatchLiterals(int index, StringSegment stringSegment, RoutePatternPathSegment pathSegment) + { + if (pathSegment.IsSimple && !pathSegment.Parts[0].IsParameter) + { + // This is a literal segment, so we need to match the text, or the route isn't a match. + var part = pathSegment.Parts[0]; + if (!stringSegment.Equals(part.RawText, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + else if (pathSegment.IsSimple && pathSegment.Parts[0].IsParameter) + { + // For a parameter, validate that it's a has some length, or we have a default, or it's optional. + var part = (RoutePatternParameter)pathSegment.Parts[0]; + if (stringSegment.Length == 0 && + !_hasDefaultValue[index] && + !part.IsOptional) + { + // There's no value for this parameter, the route can't match. + return false; + } + } + else + { + Debug.Assert(!pathSegment.IsSimple); + // Don't attempt to validate a complex segment at this point other than being non-emtpy, + // do it in the second pass. + } + return true; + } + + private bool SavePathSegmentsAsValues(int index, DispatcherValueCollection values, StringSegment requestSegment, RoutePatternPathSegment pathSegment) + { + if (pathSegment.IsSimple && pathSegment.Parts[0] is RoutePatternParameter parameter && parameter.IsCatchAll) + { + // A catch-all captures til the end of the string. + var captured = requestSegment.Buffer.Substring(requestSegment.Offset); + if (captured.Length > 0) + { + values[parameter.Name] = captured; + } + else + { + // It's ok for a catch-all to produce a null value, so we don't check _hasDefaultValue. + values[parameter.Name] = _defaultValues[index]; + } + + // A catch-all has to be the last part, so we're done. + return true; + } + else if (pathSegment.IsSimple && pathSegment.Parts[0].IsParameter) + { + // A simple parameter captures the whole segment, or a default value if nothing was + // provided. + parameter = (RoutePatternParameter)pathSegment.Parts[0]; + if (requestSegment.Length > 0) + { + values[parameter.Name] = requestSegment.ToString(); + } + else + { + if (_hasDefaultValue[index]) + { + values[parameter.Name] = _defaultValues[index]; + } + } + } + return false; + } + + private bool MatchComplexSegment( + RoutePatternPathSegment routeSegment, + string requestSegment, + IReadOnlyDictionary defaults, + DispatcherValueCollection values) + { + var indexOfLastSegment = routeSegment.Parts.Count - 1; + + // We match the request to the template starting at the rightmost parameter + // If the last segment of template is optional, then request can match the + // template with or without the last parameter. So we start with regular matching, + // but if it doesn't match, we start with next to last parameter. Example: + // Template: {p1}/{p2}.{p3?}. If the request is one/two.three it will match right away + // giving p3 value of three. But if the request is one/two, we start matching from the + // rightmost giving p3 the value of two, then we end up not matching the segment. + // In this case we start again from p2 to match the request and we succeed giving + // the value two to p2 + if (routeSegment.Parts[indexOfLastSegment] is RoutePatternParameter parameter && parameter.IsOptional && + routeSegment.Parts[indexOfLastSegment - 1].IsSeparator) + { + if (MatchComplexSegmentCore(routeSegment, requestSegment, Defaults, values, indexOfLastSegment)) + { + return true; + } + else + { + if (requestSegment.EndsWith( + routeSegment.Parts[indexOfLastSegment - 1].RawText, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return MatchComplexSegmentCore( + routeSegment, + requestSegment, + Defaults, + values, + indexOfLastSegment - 2); + } + } + else + { + return MatchComplexSegmentCore(routeSegment, requestSegment, Defaults, values, indexOfLastSegment); + } + } + + private bool MatchComplexSegmentCore( + RoutePatternPathSegment routeSegment, + string requestSegment, + IReadOnlyDictionary defaults, + DispatcherValueCollection values, + int indexOfLastSegmentUsed) + { + Debug.Assert(routeSegment != null); + Debug.Assert(routeSegment.Parts.Count > 1); + + // Find last literal segment and get its last index in the string + var lastIndex = requestSegment.Length; + + RoutePatternParameter parameterNeedsValue = null; // Keeps track of a parameter segment that is pending a value + RoutePatternPart lastLiteral = null; // Keeps track of the left-most literal we've encountered + + var outValues = new DispatcherValueCollection(); + + while (indexOfLastSegmentUsed >= 0) + { + var newLastIndex = lastIndex; + + var part = routeSegment.Parts[indexOfLastSegmentUsed]; + if (part.IsParameter) + { + // Hold on to the parameter so that we can fill it in when we locate the next literal + parameterNeedsValue = (RoutePatternParameter)part; + } + else + { + Debug.Assert(part.IsLiteral || part.IsSeparator); + lastLiteral = part; + + var startIndex = lastIndex - 1; + // If we have a pending parameter subsegment, we must leave at least one character for that + if (parameterNeedsValue != null) + { + startIndex--; + } + + if (startIndex < 0) + { + return false; + } + + var indexOfLiteral = requestSegment.LastIndexOf( + part.RawText, + startIndex, + StringComparison.OrdinalIgnoreCase); + if (indexOfLiteral == -1) + { + // If we couldn't find this literal index, this segment cannot match + return false; + } + + // If the first subsegment is a literal, it must match at the right-most extent of the request URI. + // Without this check if your route had "/Foo/" we'd match the request URI "/somethingFoo/". + // This check is related to the check we do at the very end of this function. + if (indexOfLastSegmentUsed == (routeSegment.Parts.Count - 1)) + { + if ((indexOfLiteral + part.RawText.Length) != requestSegment.Length) + { + return false; + } + } + + newLastIndex = indexOfLiteral; + } + + if ((parameterNeedsValue != null) && + (((lastLiteral != null) && !part.IsParameter) || (indexOfLastSegmentUsed == 0))) + { + // If we have a pending parameter that needs a value, grab that value + + int parameterStartIndex; + int parameterTextLength; + + if (lastLiteral == null) + { + if (indexOfLastSegmentUsed == 0) + { + parameterStartIndex = 0; + } + else + { + parameterStartIndex = newLastIndex; + Debug.Assert(false, "indexOfLastSegementUsed should always be 0 from the check above"); + } + parameterTextLength = lastIndex; + } + else + { + // If we're getting a value for a parameter that is somewhere in the middle of the segment + if ((indexOfLastSegmentUsed == 0) && (part.IsParameter)) + { + parameterStartIndex = 0; + parameterTextLength = lastIndex; + } + else + { + parameterStartIndex = newLastIndex + lastLiteral.RawText.Length; + parameterTextLength = lastIndex - parameterStartIndex; + } + } + + var parameterValueString = requestSegment.Substring(parameterStartIndex, parameterTextLength); + + if (string.IsNullOrEmpty(parameterValueString)) + { + // If we're here that means we have a segment that contains multiple sub-segments. + // For these segments all parameters must have non-empty values. If the parameter + // has an empty value it's not a match. + return false; + + } + else + { + // If there's a value in the segment for this parameter, use the subsegment value + outValues.Add(parameterNeedsValue.Name, parameterValueString); + } + + parameterNeedsValue = null; + lastLiteral = null; + } + + lastIndex = newLastIndex; + indexOfLastSegmentUsed--; + } + + // If the last subsegment is a parameter, it's OK that we didn't parse all the way to the left extent of + // the string since the parameter will have consumed all the remaining text anyway. If the last subsegment + // is a literal then we *must* have consumed the entire text in that literal. Otherwise we end up matching + // the route "Foo" to the request URI "somethingFoo". Thus we have to check that we parsed the *entire* + // request URI in order for it to be a match. + // This check is related to the check we do earlier in this function for LiteralSubsegments. + if (lastIndex == 0 || routeSegment.Parts[0].IsParameter) + { + foreach (var item in outValues) + { + values.Add(item.Key, item.Value); + } + + return true; + } + + return false; + } + } +} diff --git a/test/Microsoft.AspNetCore.Dispatcher.Test/RoutePatternMatcherTests.cs b/test/Microsoft.AspNetCore.Dispatcher.Test/RoutePatternMatcherTests.cs new file mode 100644 index 0000000000..1332fd90e9 --- /dev/null +++ b/test/Microsoft.AspNetCore.Dispatcher.Test/RoutePatternMatcherTests.cs @@ -0,0 +1,1133 @@ +// 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.AspNetCore.Dispatcher.Patterns; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Dispatcher +{ + public class RoutePatternMatcherTests + { + [Fact] + public void TryMatch_Success() + { + // Arrange + var matcher = CreateMatcher("{controller}/{action}/{id}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/Bank/DoAction/123", values); + + // Assert + Assert.True(match); + Assert.Equal("Bank", values["controller"]); + Assert.Equal("DoAction", values["action"]); + Assert.Equal("123", values["id"]); + } + + [Fact] + public void TryMatch_Fails() + { + // Arrange + var matcher = CreateMatcher("{controller}/{action}/{id}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/Bank/DoAction", values); + + // Assert + Assert.False(match); + } + + [Fact] + public void TryMatch_WithDefaults_Success() + { + // Arrange + var matcher = CreateMatcher("{controller}/{action}/{id}", new { id = "default id" }); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/Bank/DoAction", values); + + // Assert + Assert.True(match); + Assert.Equal("Bank", values["controller"]); + Assert.Equal("DoAction", values["action"]); + Assert.Equal("default id", values["id"]); + } + + [Fact] + public void TryMatch_WithDefaults_Fails() + { + // Arrange + var matcher = CreateMatcher("{controller}/{action}/{id}", new { id = "default id" }); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/Bank", values); + + // Assert + Assert.False(match); + } + + [Fact] + public void TryMatch_WithLiterals_Success() + { + // Arrange + var matcher = CreateMatcher("moo/{p1}/bar/{p2}", new { p2 = "default p2" }); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/111/bar/222", values); + + // Assert + Assert.True(match); + Assert.Equal("111", values["p1"]); + Assert.Equal("222", values["p2"]); + } + + [Fact] + public void TryMatch_RouteWithLiteralsAndDefaults_Success() + { + // Arrange + var matcher = CreateMatcher("moo/{p1}/bar/{p2}", new { p2 = "default p2" }); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/111/bar/", values); + + // Assert + Assert.True(match); + Assert.Equal("111", values["p1"]); + Assert.Equal("default p2", values["p2"]); + } + + [Theory] + [InlineData(@"{p1:regex(^\d{{3}}-\d{{3}}-\d{{4}}$)}", "/123-456-7890")] // ssn + [InlineData(@"{p1:regex(^\w+\@\w+\.\w+)}", "/asd@assds.com")] // email + [InlineData(@"{p1:regex(([}}])\w+)}", "/}sda")] // Not balanced } + [InlineData(@"{p1:regex(([{{)])\w+)}", "/})sda")] // Not balanced { + public void TryMatch_RegularExpressionConstraint_Valid( + string template, + string path) + { + // Arrange + var matcher = CreateMatcher(template); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch(path, values); + + // Assert + Assert.True(match); + } + + [Theory] + [InlineData("moo/{p1}.{p2?}", "/moo/foo.bar", true, "foo", "bar")] + [InlineData("moo/{p1?}", "/moo/foo", true, "foo", null)] + [InlineData("moo/{p1?}", "/moo", true, null, null)] + [InlineData("moo/{p1}.{p2?}", "/moo/foo", true, "foo", null)] + [InlineData("moo/{p1}.{p2?}", "/moo/foo..bar", true, "foo.", "bar")] + [InlineData("moo/{p1}.{p2?}", "/moo/foo.moo.bar", true, "foo.moo", "bar")] + [InlineData("moo/{p1}.{p2}", "/moo/foo.bar", true, "foo", "bar")] + [InlineData("moo/foo.{p1}.{p2?}", "/moo/foo.moo.bar", true, "moo", "bar")] + [InlineData("moo/foo.{p1}.{p2?}", "/moo/foo.moo", true, "moo", null)] + [InlineData("moo/.{p2?}", "/moo/.foo", true, null, "foo")] + [InlineData("moo/.{p2?}", "/moo", false, null, null)] + [InlineData("moo/{p1}.{p2?}", "/moo/....", true, "..", ".")] + [InlineData("moo/{p1}.{p2?}", "/moo/.bar", true, ".bar", null)] + public void TryMatch_OptionalParameter_FollowedByPeriod_Valid( + string template, + string path, + bool expectedMatch, + string p1, + string p2) + { + // Arrange + var matcher = CreateMatcher(template); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch(path, values); + + // Assert + Assert.Equal(expectedMatch, match); + if (p1 != null) + { + Assert.Equal(p1, values["p1"]); + } + if (p2 != null) + { + Assert.Equal(p2, values["p2"]); + } + } + + [Theory] + [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")] + [InlineData("{p1}/{p2}/{p3?}", "/foo/bar/baz", "foo", "bar", "baz")] + public void TryMatch_OptionalParameter_FollowedByPeriod_3Parameters_Valid( + string template, + string path, + string p1, + string p2, + string p3) + { + // Arrange + var matcher = CreateMatcher(template); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch(path, values); + + // Assert + Assert.True(match); + Assert.Equal(p1, values["p1"]); + + if (p2 != null) + { + Assert.Equal(p2, values["p2"]); + } + + if (p3 != null) + { + Assert.Equal(p3, 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 void TryMatch_OptionalParameter_FollowedByPeriod_Invalid(string template, string path) + { + // Arrange + var matcher = CreateMatcher(template); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch(path, values); + + // Assert + Assert.False(match); + } + + [Fact] + public void TryMatch_RouteWithOnlyLiterals_Success() + { + // Arrange + var matcher = CreateMatcher("moo/bar"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/bar", values); + + // Assert + Assert.True(match); + Assert.Empty(values); + } + + [Fact] + public void TryMatch_RouteWithOnlyLiterals_Fails() + { + // Arrange + var matcher = CreateMatcher("moo/bars"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/bar", values); + + // Assert + Assert.False(match); + } + + [Fact] + public void TryMatch_RouteWithExtraSeparators_Success() + { + // Arrange + var matcher = CreateMatcher("moo/bar"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/bar/", values); + + // Assert + Assert.True(match); + Assert.Empty(values); + } + + [Fact] + public void TryMatch_UrlWithExtraSeparators_Success() + { + // Arrange + var matcher = CreateMatcher("moo/bar/"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/bar", values); + + // Assert + Assert.True(match); + Assert.Empty(values); + } + + [Fact] + public void TryMatch_RouteWithParametersAndExtraSeparators_Success() + { + // Arrange + var matcher = CreateMatcher("{p1}/{p2}/"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/bar", values); + + // Assert + Assert.True(match); + Assert.Equal("moo", values["p1"]); + Assert.Equal("bar", values["p2"]); + } + + [Fact] + public void TryMatch_RouteWithDifferentLiterals_Fails() + { + // Arrange + var matcher = CreateMatcher("{p1}/{p2}/baz"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/bar/boo", values); + + // Assert + Assert.False(match); + } + + [Fact] + public void TryMatch_LongerUrl_Fails() + { + // Arrange + var matcher = CreateMatcher("{p1}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/moo/bar", values); + + // Assert + Assert.False(match); + } + + [Fact] + public void TryMatch_SimpleFilename_Success() + { + // Arrange + var matcher = CreateMatcher("DEFAULT.ASPX"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/default.aspx", values); + + // Assert + Assert.True(match); + } + + [Theory] + [InlineData("{prefix}x{suffix}", "/xxxxxxxxxx")] + [InlineData("{prefix}xyz{suffix}", "/xxxxyzxyzxxxxxxyz")] + [InlineData("{prefix}xyz{suffix}", "/abcxxxxyzxyzxxxxxxyzxx")] + [InlineData("{prefix}xyz{suffix}", "/xyzxyzxyzxyzxyz")] + [InlineData("{prefix}xyz{suffix}", "/xyzxyzxyzxyzxyz1")] + [InlineData("{prefix}xyz{suffix}", "/xyzxyzxyz")] + [InlineData("{prefix}aa{suffix}", "/aaaaa")] + [InlineData("{prefix}aaa{suffix}", "/aaaaa")] + public void TryMatch_RouteWithComplexSegment_Success(string template, string path) + { + var matcher = CreateMatcher(template); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch(path, values); + + // Assert + Assert.True(match); + } + + [Fact] + public void TryMatch_RouteWithExtraDefaultValues_Success() + { + // Arrange + var matcher = CreateMatcher("{p1}/{p2}", new { p2 = (string)null, foo = "bar" }); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/v1", values); + + // Assert + Assert.True(match); + Assert.Equal(3, values.Count); + Assert.Equal("v1", values["p1"]); + Assert.Null(values["p2"]); + Assert.Equal("bar", values["foo"]); + } + + [Fact] + public void TryMatch_PrettyRouteWithExtraDefaultValues_Success() + { + // Arrange + var matcher = CreateMatcher( + "date/{y}/{m}/{d}", + new { controller = "blog", action = "showpost", m = (string)null, d = (string)null }); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/date/2007/08", values); + + // Assert + Assert.True(match); + Assert.Equal(5, values.Count); + Assert.Equal("blog", values["controller"]); + Assert.Equal("showpost", values["action"]); + Assert.Equal("2007", values["y"]); + Assert.Equal("08", values["m"]); + Assert.Null(values["d"]); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnBothEndsMatches() + { + RunTest( + "language/{lang}-{region}", + "/language/en-US", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnLeftEndMatches() + { + RunTest( + "language/{lang}-{region}a", + "/language/en-USa", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnRightEndMatches() + { + RunTest( + "language/a{lang}-{region}", + "/language/aen-US", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnNeitherEndMatches() + { + RunTest( + "language/a{lang}-{region}a", + "/language/aen-USa", + null, + new DispatcherValueCollection(new { lang = "en", region = "US" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnNeitherEndDoesNotMatch() + { + RunTest( + "language/a{lang}-{region}a", + "/language/a-USa", + null, + null); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnNeitherEndDoesNotMatch2() + { + RunTest( + "language/a{lang}-{region}a", + "/language/aen-a", + null, + null); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnBothEndsMatches() + { + RunTest( + "language/{lang}", + "/language/en", + null, + new DispatcherValueCollection(new { lang = "en" })); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnBothEndsTrailingSlashDoesNotMatch() + { + RunTest( + "language/{lang}", + "/language/", + null, + null); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnBothEndsDoesNotMatch() + { + RunTest( + "language/{lang}", + "/language", + null, + null); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnLeftEndMatches() + { + RunTest( + "language/{lang}-", + "/language/en-", + null, + new DispatcherValueCollection(new { lang = "en" })); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnRightEndMatches() + { + RunTest( + "language/a{lang}", + "/language/aen", + null, + new DispatcherValueCollection(new { lang = "en" })); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnNeitherEndMatches() + { + RunTest( + "language/a{lang}a", + "/language/aena", + null, + new DispatcherValueCollection(new { lang = "en" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentStandamatchMvcRouteMatches() + { + RunTest( + "{controller}.mvc/{action}/{id}", + "/home.mvc/index", + new DispatcherValueCollection(new { action = "Index", id = (string)null }), + new DispatcherValueCollection(new { controller = "home", action = "index", id = (string)null })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnBothEndsWithDefaultValuesMatches() + { + RunTest( + "language/{lang}-{region}", + "/language/-", + new DispatcherValueCollection(new { lang = "xx", region = "yy" }), + null); + } + + [Fact] + public void TryMatch_WithUrlWithMultiSegmentWithRepeatedDots() + { + RunTest( + "{Controller}..mvc/{id}/{Param1}", + "/Home..mvc/123/p1", + null, + new DispatcherValueCollection(new { Controller = "Home", id = "123", Param1 = "p1" })); + } + + [Fact] + public void TryMatch_WithUrlWithTwoRepeatedDots() + { + RunTest( + "{Controller}.mvc/../{action}", + "/Home.mvc/../index", + null, + new DispatcherValueCollection(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithThreeRepeatedDots() + { + RunTest( + "{Controller}.mvc/.../{action}", + "/Home.mvc/.../index", + null, + new DispatcherValueCollection(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithManyRepeatedDots() + { + RunTest( + "{Controller}.mvc/../../../{action}", + "/Home.mvc/../../../index", + null, + new DispatcherValueCollection(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithExclamationPoint() + { + RunTest( + "{Controller}.mvc!/{action}", + "/Home.mvc!/index", + null, + new DispatcherValueCollection(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithStartingDotDotSlash() + { + RunTest( + "../{Controller}.mvc", + "/../Home.mvc", + null, + new DispatcherValueCollection(new { Controller = "Home" })); + } + + [Fact] + public void TryMatch_WithUrlWithStartingBackslash() + { + RunTest( + @"\{Controller}.mvc", + @"/\Home.mvc", + null, + new DispatcherValueCollection(new { Controller = "Home" })); + } + + [Fact] + public void TryMatch_WithUrlWithBackslashSeparators() + { + RunTest( + @"{Controller}.mvc\{id}\{Param1}", + @"/Home.mvc\123\p1", + null, + new DispatcherValueCollection(new { Controller = "Home", id = "123", Param1 = "p1" })); + } + + [Fact] + public void TryMatch_WithUrlWithParenthesesLiterals() + { + RunTest( + @"(Controller).mvc", + @"/(Controller).mvc", + null, + new DispatcherValueCollection()); + } + + [Fact] + public void TryMatch_WithUrlWithTrailingSlashSpace() + { + RunTest( + @"Controller.mvc/ ", + @"/Controller.mvc/ ", + null, + new DispatcherValueCollection()); + } + + [Fact] + public void TryMatch_WithUrlWithTrailingSpace() + { + RunTest( + @"Controller.mvc ", + @"/Controller.mvc ", + null, + new DispatcherValueCollection()); + } + + [Fact] + public void TryMatch_WithCatchAllCapturesDots() + { + // DevDiv Bugs 189892: UrlRouting: Catch all parameter cannot capture url segments that contain the "." + RunTest( + "Home/ShowPilot/{missionId}/{*name}", + "/Home/ShowPilot/777/12345./foobar", + new DispatcherValueCollection(new + { + controller = "Home", + action = "ShowPilot", + missionId = (string)null, + name = (string)null + }), + new DispatcherValueCollection(new { controller = "Home", action = "ShowPilot", missionId = "777", name = "12345./foobar" })); + } + + [Fact] + public void TryMatch_RouteWithCatchAll_MatchesMultiplePathSegments() + { + // Arrange + var matcher = CreateMatcher("{p1}/{*p2}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/v1/v2/v3", values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("v1", values["p1"]); + Assert.Equal("v2/v3", values["p2"]); + } + + [Fact] + public void TryMatch_RouteWithCatchAll_MatchesTrailingSlash() + { + // Arrange + var matcher = CreateMatcher("{p1}/{*p2}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/v1/", values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("v1", values["p1"]); + Assert.Null(values["p2"]); + } + + [Fact] + public void TryMatch_RouteWithCatchAll_MatchesEmptyContent() + { + // Arrange + var matcher = CreateMatcher("{p1}/{*p2}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch("/v1", values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("v1", values["p1"]); + Assert.Null(values["p2"]); + } + + [Fact] + public void TryMatch_RouteWithCatchAll_MatchesEmptyContent_DoesNotReplaceExistingRouteValue() + { + // Arrange + var matcher = CreateMatcher("{p1}/{*p2}"); + + var values = new DispatcherValueCollection(new { p2 = "hello" }); + + // Act + var match = matcher.TryMatch("/v1", values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("v1", values["p1"]); + Assert.Equal("hello", values["p2"]); + } + + [Fact] + public void TryMatch_RouteWithCatchAll_UsesDefaultValueForEmptyContent() + { + // Arrange + var matcher = CreateMatcher("{p1}/{*p2}", new { p2 = "catchall" }); + + var values = new DispatcherValueCollection(new { p2 = "overridden" }); + + // Act + var match = matcher.TryMatch("/v1", values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("v1", values["p1"]); + Assert.Equal("catchall", values["p2"]); + } + + [Fact] + public void TryMatch_RouteWithCatchAll_IgnoresDefaultValueForNonEmptyContent() + { + // Arrange + var matcher = CreateMatcher("{p1}/{*p2}", new { p2 = "catchall" }); + + var values = new DispatcherValueCollection(new { p2 = "overridden" }); + + // Act + var match = matcher.TryMatch("/v1/hello/whatever", values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("v1", values["p1"]); + Assert.Equal("hello/whatever", values["p2"]); + } + + [Fact] + public void TryMatch_DoesNotMatchOnlyLeftLiteralMatch() + { + // DevDiv Bugs 191180: UrlRouting: Wrong template getting matched if a url segment is a substring of the requested url + RunTest( + "foo", + "/fooBAR", + null, + null); + } + + [Fact] + public void TryMatch_DoesNotMatchOnlyRightLiteralMatch() + { + // DevDiv Bugs 191180: UrlRouting: Wrong template getting matched if a url segment is a substring of the requested url + RunTest( + "foo", + "/BARfoo", + null, + null); + } + + [Fact] + public void TryMatch_DoesNotMatchMiddleLiteralMatch() + { + // DevDiv Bugs 191180: UrlRouting: Wrong template getting matched if a url segment is a substring of the requested url + RunTest( + "foo", + "/BARfooBAR", + null, + null); + } + + [Fact] + public void TryMatch_DoesMatchesExactLiteralMatch() + { + // DevDiv Bugs 191180: UrlRouting: Wrong template getting matched if a url segment is a substring of the requested url + RunTest( + "foo", + "/foo", + null, + new DispatcherValueCollection()); + } + + [Fact] + public void TryMatch_WithWeimatchParameterNames() + { + RunTest( + "foo/{ }/{.!$%}/{dynamic.data}/{op.tional}", + "/foo/space/weimatch/omatcherid", + new DispatcherValueCollection() { { " ", "not a space" }, { "op.tional", "default value" }, { "ran!dom", "va@lue" } }, + new DispatcherValueCollection() { { " ", "space" }, { ".!$%", "weimatch" }, { "dynamic.data", "omatcherid" }, { "op.tional", "default value" }, { "ran!dom", "va@lue" } }); + } + + [Fact] + public void TryMatch_DoesNotMatchRouteWithLiteralSeparatomatchefaultsButNoValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo", + new DispatcherValueCollection(new { language = "en", locale = "US" }), + null); + } + + [Fact] + public void TryMatch_DoesNotMatchesRouteWithLiteralSeparatomatchefaultsAndLeftValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo/xx-", + new DispatcherValueCollection(new { language = "en", locale = "US" }), + null); + } + + [Fact] + public void TryMatch_DoesNotMatchesRouteWithLiteralSeparatomatchefaultsAndRightValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo/-yy", + new DispatcherValueCollection(new { language = "en", locale = "US" }), + null); + } + + [Fact] + public void TryMatch_MatchesRouteWithLiteralSeparatomatchefaultsAndValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo/xx-yy", + new DispatcherValueCollection(new { language = "en", locale = "US" }), + new DispatcherValueCollection { { "language", "xx" }, { "locale", "yy" }, { "controller", "foo" } }); + } + + [Fact] + public void TryMatch_SetsOptionalParameter() + { + // Arrange + var route = CreateMatcher("{controller}/{action?}"); + var url = "/Home/Index"; + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("Home", values["controller"]); + Assert.Equal("Index", values["action"]); + } + + [Fact] + public void TryMatch_DoesNotSetOptionalParameter() + { + // Arrange + var route = CreateMatcher("{controller}/{action?}"); + var url = "/Home"; + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.True(match); + Assert.Single(values); + Assert.Equal("Home", values["controller"]); + Assert.False(values.ContainsKey("action")); + } + + [Fact] + public void TryMatch_DoesNotSetOptionalParameter_EmptyString() + { + // Arrange + var route = CreateMatcher("{controller?}"); + var url = ""; + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.True(match); + Assert.Empty(values); + Assert.False(values.ContainsKey("controller")); + } + + [Fact] + public void TryMatch__EmptyRouteWith_EmptyString() + { + // Arrange + var route = CreateMatcher(""); + var url = ""; + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.True(match); + Assert.Empty(values); + } + + [Fact] + public void TryMatch_MultipleOptionalParameters() + { + // Arrange + var route = CreateMatcher("{controller}/{action?}/{id?}"); + var url = "/Home/Index"; + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.True(match); + Assert.Equal(2, values.Count); + Assert.Equal("Home", values["controller"]); + Assert.Equal("Index", values["action"]); + Assert.False(values.ContainsKey("id")); + } + + [Theory] + [InlineData("///")] + [InlineData("/a//")] + [InlineData("/a/b//")] + [InlineData("//b//")] + [InlineData("///c")] + [InlineData("///c/")] + public void TryMatch_MultipleOptionalParameters_WithEmptyIntermediateSegmentsDoesNotMatch(string url) + { + // Arrange + var route = CreateMatcher("{controller?}/{action?}/{id?}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.False(match); + } + + [Theory] + [InlineData("")] + [InlineData("/")] + [InlineData("/a")] + [InlineData("/a/")] + [InlineData("/a/b")] + [InlineData("/a/b/")] + [InlineData("/a/b/c")] + [InlineData("/a/b/c/")] + public void TryMatch_MultipleOptionalParameters_WithIncrementalOptionalValues(string url) + { + // Arrange + var route = CreateMatcher("{controller?}/{action?}/{id?}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.True(match); + } + + [Theory] + [InlineData("///")] + [InlineData("////")] + [InlineData("/a//")] + [InlineData("/a///")] + [InlineData("//b/")] + [InlineData("//b//")] + [InlineData("///c")] + [InlineData("///c/")] + public void TryMatch_MultipleParameters_WithEmptyValues(string url) + { + // Arrange + var route = CreateMatcher("{controller}/{action}/{id}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.False(match); + } + + [Theory] + [InlineData("/a/b/c//")] + [InlineData("/a/b/c/////")] + public void TryMatch_CatchAllParameters_WithEmptyValuesAtTheEnd(string url) + { + // Arrange + var route = CreateMatcher("{controller}/{action}/{*id}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.True(match); + } + + [Theory] + [InlineData("/a/b//")] + [InlineData("/a/b///c")] + public void TryMatch_CatchAllParameters_WithEmptyValues(string url) + { + // Arrange + var route = CreateMatcher("{controller}/{action}/{*id}"); + + var values = new DispatcherValueCollection(); + + // Act + var match = route.TryMatch(url, values); + + // Assert + Assert.False(match); + } + + private RoutePatternMatcher CreateMatcher(string template, object defaults = null) + { + return new RoutePatternMatcher( + RoutePatternParser.Parse(template), + new DispatcherValueCollection(defaults)); + } + + private static void RunTest( + string template, + string path, + DispatcherValueCollection defaults, + IDictionary expected) + { + // Arrange + var matcher = new RoutePatternMatcher( + RoutePatternParser.Parse(template), + defaults ?? new DispatcherValueCollection()); + + var values = new DispatcherValueCollection(); + + // Act + var match = matcher.TryMatch(new PathString(path), values); + + // Assert + if (expected == null) + { + Assert.False(match); + } + else + { + Assert.True(match); + Assert.Equal(expected.Count, values.Count); + foreach (string key in values.Keys) + { + Assert.Equal(expected[key], values[key]); + } + } + } + } +}