From 9e114b547dffc838a3713a80a059249f8d25842c Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Fri, 13 Jul 2018 18:01:46 -0700 Subject: [PATCH] Introduce RoutePattern (#585) * Introduce RoutePattern Introduces RoutePattern - a new parser and representation for routing templates, defaults, and constraints. This is a new representation for all of the 'inputs' to routing that is immutable and captures 'out of line' information for defaults and constraints. This will allow us to unify the handling of constraints and values from attribute style routes and conventional style routes. --- .../Patterns/RouteParameterParser.cs | 248 ++++ .../Patterns/RoutePattern.cs | 74 ++ .../RoutePatternConstraintReference.cs | 46 + .../Patterns/RoutePatternException.cs | 42 + .../Patterns/RoutePatternFactory.cs | 504 ++++++++ .../Patterns/RoutePatternLiteralPart.cs | 25 + .../Patterns/RoutePatternMatcher.cs | 511 ++++++++ .../Patterns/RoutePatternParameterKind.cs | 12 + .../Patterns/RoutePatternParameterPart.cs | 73 ++ .../Patterns/RoutePatternParser.cs | 577 +++++++++ .../Patterns/RoutePatternPart.cs | 27 + .../Patterns/RoutePatternPartKind.cs | 12 + .../Patterns/RoutePatternPathSegment.cs | 32 + .../Patterns/RoutePatternSeparatorPart.cs | 26 + .../Properties/Resources.Designer.cs | 42 + .../Resources.resx | 9 + .../RouteCollection.cs | 2 +- .../Template/InlineConstraint.cs | 11 + .../Template/RouteTemplate.cs | 31 + .../Template/TemplateMatcher.cs | 390 +----- .../Template/TemplateParser.cs | 528 +------- .../Template/TemplatePart.cs | 69 +- .../Template/TemplateSegment.cs | 19 +- .../InlineRouteParameterParserTest.cs | 946 ++++++++++++++ .../Patterns/RoutePatternFactoryTest.cs | 247 ++++ .../Patterns/RoutePatternMatcherTest.cs | 1131 +++++++++++++++++ .../Patterns/RoutePatternParserTest.cs | 765 +++++++++++ .../Template/TemplateParserTests.cs | 31 +- 28 files changed, 5503 insertions(+), 927 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RouteParameterParser.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternConstraintReference.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternException.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternFactory.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternLiteralPart.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterKind.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterPart.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParser.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPart.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPartKind.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPathSegment.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternSeparatorPart.cs create mode 100644 test/Microsoft.AspNetCore.Routing.Tests/Patterns/InlineRouteParameterParserTest.cs create mode 100644 test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternFactoryTest.cs create mode 100644 test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternMatcherTest.cs create mode 100644 test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternParserTest.cs diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RouteParameterParser.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RouteParameterParser.cs new file mode 100644 index 0000000000..c5b1a008e1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RouteParameterParser.cs @@ -0,0 +1,248 @@ +// 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.AspNetCore.Routing.Patterns +{ + internal static class RouteParameterParser + { + // This code parses the inside of the route parameter + // + // Ex: {hello} - this method is responsible for parsing 'hello' + // The factoring between this class and RoutePatternParser is due to legacy. + public static RoutePatternParameterPart ParseRouteParameter(string parameter) + { + if (parameter == null) + { + throw new ArgumentNullException(nameof(parameter)); + } + + if (parameter.Length == 0) + { + return new RoutePatternParameterPart(string.Empty, null, RoutePatternParameterKind.Standard, Array.Empty()); + } + + var startIndex = 0; + var endIndex = parameter.Length - 1; + + var parameterKind = RoutePatternParameterKind.Standard; + if (parameter[0] == '*') + { + parameterKind = RoutePatternParameterKind.CatchAll; + startIndex++; + } + + if (parameter[endIndex] == '?') + { + parameterKind = RoutePatternParameterKind.Optional; + endIndex--; + } + + var currentIndex = startIndex; + + // Parse parameter name + var parameterName = string.Empty; + + while (currentIndex <= endIndex) + { + var currentChar = parameter[currentIndex]; + + if ((currentChar == ':' || currentChar == '=') && startIndex != currentIndex) + { + // Parameter names are allowed to start with delimiters used to denote constraints or default values. + // i.e. "=foo" or ":bar" would be treated as parameter names rather than default value or constraint + // specifications. + parameterName = parameter.Substring(startIndex, currentIndex - startIndex); + + // Roll the index back and move to the constraint parsing stage. + currentIndex--; + break; + } + else if (currentIndex == endIndex) + { + parameterName = parameter.Substring(startIndex, currentIndex - startIndex + 1); + } + + currentIndex++; + } + + var parseResults = ParseConstraints(parameter, parameterName, currentIndex, endIndex); + currentIndex = parseResults.CurrentIndex; + + string defaultValue = null; + if (currentIndex <= endIndex && + parameter[currentIndex] == '=') + { + defaultValue = parameter.Substring(currentIndex + 1, endIndex - currentIndex); + } + + return new RoutePatternParameterPart(parameterName, defaultValue, parameterKind, parseResults.Constraints.ToArray()); + } + + private static ConstraintParseResults ParseConstraints( + string text, + string parameterName, + int currentIndex, + int endIndex) + { + var constraints = new List(); + var state = ParseState.Start; + var startIndex = currentIndex; + do + { + var currentChar = currentIndex > endIndex ? null : (char?)text[currentIndex]; + switch (state) + { + case ParseState.Start: + switch (currentChar) + { + case null: + state = ParseState.End; + break; + case ':': + state = ParseState.ParsingName; + startIndex = currentIndex + 1; + break; + case '(': + state = ParseState.InsideParenthesis; + break; + case '=': + state = ParseState.End; + currentIndex--; + break; + } + break; + case ParseState.InsideParenthesis: + switch (currentChar) + { + case null: + state = ParseState.End; + var constraintText = text.Substring(startIndex, currentIndex - startIndex); + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + break; + case ')': + // Only consume a ')' token if + // (a) it is the last token + // (b) the next character is the start of the new constraint ':' + // (c) the next character is the start of the default value. + + var nextChar = currentIndex + 1 > endIndex ? null : (char?)text[currentIndex + 1]; + switch (nextChar) + { + case null: + state = ParseState.End; + constraintText = text.Substring(startIndex, currentIndex - startIndex + 1); + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + break; + case ':': + state = ParseState.Start; + constraintText = text.Substring(startIndex, currentIndex - startIndex + 1); + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + startIndex = currentIndex + 1; + break; + case '=': + state = ParseState.End; + constraintText = text.Substring(startIndex, currentIndex - startIndex + 1); + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + break; + } + break; + case ':': + case '=': + // In the original implementation, the Regex would've backtracked if it encountered an + // unbalanced opening bracket followed by (not necessarily immediatiely) a delimiter. + // Simply verifying that the parantheses will eventually be closed should suffice to + // determine if the terminator needs to be consumed as part of the current constraint + // specification. + var indexOfClosingParantheses = text.IndexOf(')', currentIndex + 1); + if (indexOfClosingParantheses == -1) + { + constraintText = text.Substring(startIndex, currentIndex - startIndex); + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + + if (currentChar == ':') + { + state = ParseState.ParsingName; + startIndex = currentIndex + 1; + } + else + { + state = ParseState.End; + currentIndex--; + } + } + else + { + currentIndex = indexOfClosingParantheses; + } + + break; + } + break; + case ParseState.ParsingName: + switch (currentChar) + { + case null: + state = ParseState.End; + var constraintText = text.Substring(startIndex, currentIndex - startIndex); + if (constraintText.Length > 0) + { + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + } + break; + case ':': + constraintText = text.Substring(startIndex, currentIndex - startIndex); + if (constraintText.Length > 0) + { + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + } + startIndex = currentIndex + 1; + break; + case '(': + state = ParseState.InsideParenthesis; + break; + case '=': + state = ParseState.End; + constraintText = text.Substring(startIndex, currentIndex - startIndex); + if (constraintText.Length > 0) + { + constraints.Add(RoutePatternFactory.Constraint(parameterName, constraintText)); + } + currentIndex--; + break; + } + break; + } + + currentIndex++; + + } while (state != ParseState.End); + + return new ConstraintParseResults(currentIndex, constraints); + } + + private enum ParseState + { + Start, + ParsingName, + InsideParenthesis, + End + } + + private readonly struct ConstraintParseResults + { + public readonly int CurrentIndex; + + public readonly IReadOnlyList Constraints; + + public ConstraintParseResults(int currentIndex, IReadOnlyList constraints) + { + CurrentIndex = currentIndex; + Constraints = constraints; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs new file mode 100644 index 0000000000..74757849c9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePattern.cs @@ -0,0 +1,74 @@ +// 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 System.Linq; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + [DebuggerDisplay("{DebuggerToString()}")] + public sealed class RoutePattern + { + private const string SeparatorString = "/"; + + internal RoutePattern( + string rawText, + Dictionary defaults, + Dictionary> constraints, + RoutePatternParameterPart[] parameters, + RoutePatternPathSegment[] pathSegments) + { + Debug.Assert(defaults != null); + Debug.Assert(constraints != null); + Debug.Assert(parameters != null); + Debug.Assert(pathSegments != null); + + RawText = rawText; + Defaults = defaults; + Constraints = constraints; + Parameters = parameters; + PathSegments = pathSegments; + } + + public IReadOnlyDictionary Defaults { get; } + + public IReadOnlyDictionary> Constraints { get; } + + public string RawText { get; } + + public IReadOnlyList Parameters { get; } + + public IReadOnlyList PathSegments { get; } + + /// + /// Gets the parameter matching the given name. + /// + /// The name of the parameter to match. + /// The matching parameter or null if no parameter matches the given name. + public RoutePatternParameterPart GetParameter(string name) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + for (var i = 0; i < Parameters.Count; i++) + { + var parameter = Parameters[i]; + if (string.Equals(parameter.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return parameter; + } + } + + return null; + } + + private string DebuggerToString() + { + return RawText ?? string.Join(SeparatorString, PathSegments.Select(s => s.DebuggerToString())); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternConstraintReference.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternConstraintReference.cs new file mode 100644 index 0000000000..272282a63f --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternConstraintReference.cs @@ -0,0 +1,46 @@ +// 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.Diagnostics; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + /// + /// The parsed representation of a constraint in a parameter. + /// + [DebuggerDisplay("{DebuggerToString()}")] + public sealed class RoutePatternConstraintReference + { + internal RoutePatternConstraintReference(string parameterName, string content) + { + ParameterName = parameterName; + Content = content; + } + + internal RoutePatternConstraintReference(string parameterName, IRouteConstraint constraint) + { + ParameterName = parameterName; + Constraint = constraint; + } + + /// + /// Gets the constraint text. + /// + public string Content { get; } + + /// + /// Gets a pre-existing that was used to construct this reference. + /// + public IRouteConstraint Constraint { get; } + + /// + /// Gets the parameter name associated with the constraint. + /// + public string ParameterName { get; } + + private string DebuggerToString() + { + return Content; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternException.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternException.cs new file mode 100644 index 0000000000..6399145256 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternException.cs @@ -0,0 +1,42 @@ +// 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.Runtime.Serialization; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + [Serializable] + public sealed class RoutePatternException : Exception + { + private RoutePatternException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + Pattern = (string)info.GetValue(nameof(Pattern), typeof(string)); + } + + public RoutePatternException(string pattern, string message) + : base(message) + { + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + Pattern = pattern; + } + + public string Pattern { get; } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue(nameof(Pattern), Pattern); + base.GetObjectData(info, context); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternFactory.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternFactory.cs new file mode 100644 index 0000000000..e653845f87 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternFactory.cs @@ -0,0 +1,504 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Routing.Constraints; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + public static class RoutePatternFactory + { + public static RoutePattern Parse(string pattern) + { + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + return RoutePatternParser.Parse(pattern); + } + + public static RoutePattern Parse(string pattern, object defaults, object constraints) + { + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + var original = RoutePatternParser.Parse(pattern); + return Pattern(original.RawText, defaults, constraints, original.PathSegments); + } + + public static RoutePattern Pattern(IEnumerable segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(null, null, null, segments); + } + + public static RoutePattern Pattern(string rawText, IEnumerable segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(rawText, null, null, segments); + } + + public static RoutePattern Pattern( + object defaults, + object constraints, + IEnumerable segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(null, new RouteValueDictionary(defaults), new RouteValueDictionary(constraints), segments); + } + + public static RoutePattern Pattern( + string rawText, + object defaults, + object constraints, + IEnumerable segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(rawText, new RouteValueDictionary(defaults), new RouteValueDictionary(constraints), segments); + } + + public static RoutePattern Pattern(params RoutePatternPathSegment[] segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(null, null, null, segments); + } + + public static RoutePattern Pattern(string rawText, params RoutePatternPathSegment[] segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(rawText, null, null, segments); + } + + public static RoutePattern Pattern( + object defaults, + object constraints, + params RoutePatternPathSegment[] segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(null, new RouteValueDictionary(defaults), new RouteValueDictionary(constraints), segments); + } + + public static RoutePattern Pattern( + string rawText, + object defaults, + object constraints, + params RoutePatternPathSegment[] segments) + { + if (segments == null) + { + throw new ArgumentNullException(nameof(segments)); + } + + return PatternCore(rawText, new RouteValueDictionary(defaults), new RouteValueDictionary(constraints), segments); + } + + private static RoutePattern PatternCore( + string rawText, + IDictionary defaults, + IDictionary constraints, + IEnumerable segments) + { + // We want to merge the segment data with the 'out of line' defaults and constraints. + // + // This means that for parameters that have 'out of line' defaults we will modify + // the parameter to contain the default (same story for constraints). + // + // We also maintain a collection of defaults and constraints that will also + // contain the values that don't match a parameter. + // + // It's important that these two views of the data are consistent. We don't want + // values specified out of line to have a different behavior. + + var updatedDefaults = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (defaults != null) + { + foreach (var kvp in defaults) + { + updatedDefaults.Add(kvp.Key, kvp.Value); + } + } + + var updatedConstraints = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (constraints != null) + { + foreach (var kvp in constraints) + { + updatedConstraints.Add(kvp.Key, new List() + { + Constraint(kvp.Key, kvp.Value), + }); + } + } + + var parameters = new List(); + var updatedSegments = segments.ToArray(); + for (var i = 0; i < updatedSegments.Length; i++) + { + var segment = VisitSegment(updatedSegments[i]); + updatedSegments[i] = segment; + + for (var j = 0; j < segment.Parts.Count; j++) + { + if (segment.Parts[j] is RoutePatternParameterPart parameter) + { + parameters.Add(parameter); + } + } + } + + return new RoutePattern( + rawText, + updatedDefaults, + updatedConstraints.ToDictionary(kvp => kvp.Key, kvp => (IReadOnlyList)kvp.Value.ToArray()), + parameters.ToArray(), + updatedSegments.ToArray()); + + RoutePatternPathSegment VisitSegment(RoutePatternPathSegment segment) + { + var updatedParts = new RoutePatternPart[segment.Parts.Count]; + for (var i = 0; i < segment.Parts.Count; i++) + { + var part = segment.Parts[i]; + updatedParts[i] = VisitPart(part); + } + + return SegmentCore(updatedParts); + } + + RoutePatternPart VisitPart(RoutePatternPart part) + { + if (!part.IsParameter) + { + return part; + } + + var parameter = (RoutePatternParameterPart)part; + var @default = parameter.Default; + + if (updatedDefaults.TryGetValue(parameter.Name, out var newDefault)) + { + if (parameter.Default != null) + { + var message = Resources.FormatTemplateRoute_CannotHaveDefaultValueSpecifiedInlineAndExplicitly(parameter.Name); + throw new InvalidOperationException(message); + } + + if (parameter.IsOptional) + { + var message = Resources.TemplateRoute_OptionalCannotHaveDefaultValue; + throw new InvalidOperationException(message); + } + + @default = newDefault; + } + + if (parameter.Default != null) + { + updatedDefaults.Add(parameter.Name, parameter.Default); + } + + if (!updatedConstraints.TryGetValue(parameter.Name, out var parameterConstraints) && + parameter.Constraints.Count > 0) + { + parameterConstraints = new List(); + updatedConstraints.Add(parameter.Name, parameterConstraints); + } + + if (parameter.Constraints.Count > 0) + { + parameterConstraints.AddRange(parameter.Constraints); + } + + return ParameterPartCore( + parameter.Name, + @default, + parameter.ParameterKind, + (IEnumerable)parameterConstraints ?? Array.Empty()); + } + } + + public static RoutePatternPathSegment Segment(IEnumerable parts) + { + if (parts == null) + { + throw new ArgumentNullException(nameof(parts)); + } + + return SegmentCore(parts); + } + + public static RoutePatternPathSegment Segment(params RoutePatternPart[] parts) + { + if (parts == null) + { + throw new ArgumentNullException(nameof(parts)); + } + + return SegmentCore(parts); + } + + private static RoutePatternPathSegment SegmentCore(IEnumerable parts) + { + return new RoutePatternPathSegment(parts.ToArray()); + } + + public static RoutePatternLiteralPart LiteralPart(string content) + { + if (string.IsNullOrEmpty(content)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(content)); + } + + if (content.IndexOf('?') >= 0) + { + throw new ArgumentException(Resources.FormatTemplateRoute_InvalidLiteral(content)); + } + + return LiteralPartCore(content); + } + + private static RoutePatternLiteralPart LiteralPartCore(string content) + { + return new RoutePatternLiteralPart(content); + } + + public static RoutePatternSeparatorPart SeparatorPart(string content) + { + if (string.IsNullOrEmpty(content)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(content)); + } + + return SeparatorPartCore(content); + } + + private static RoutePatternSeparatorPart SeparatorPartCore(string content) + { + return new RoutePatternSeparatorPart(content); + } + + public static RoutePatternParameterPart ParameterPart(string parameterName) + { + if (string.IsNullOrEmpty(parameterName)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(parameterName)); + } + + if (parameterName.IndexOfAny(RoutePatternParser.InvalidParameterNameChars) >= 0) + { + throw new ArgumentException(Resources.FormatTemplateRoute_InvalidParameterName(parameterName)); + } + + return ParameterPartCore( + parameterName: parameterName, + @default: null, + parameterKind: RoutePatternParameterKind.Standard, + constraints: Array.Empty()); + } + + public static RoutePatternParameterPart ParameterPart(string parameterName, object @default) + { + if (string.IsNullOrEmpty(parameterName)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(parameterName)); + } + + if (parameterName.IndexOfAny(RoutePatternParser.InvalidParameterNameChars) >= 0) + { + throw new ArgumentException(Resources.FormatTemplateRoute_InvalidParameterName(parameterName)); + } + + return ParameterPartCore( + parameterName: parameterName, + @default: @default, + parameterKind: RoutePatternParameterKind.Standard, + constraints: Array.Empty()); + } + + public static RoutePatternParameterPart ParameterPart( + string parameterName, + object @default, + RoutePatternParameterKind parameterKind) + { + if (string.IsNullOrEmpty(parameterName)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(parameterName)); + } + + if (parameterName.IndexOfAny(RoutePatternParser.InvalidParameterNameChars) >= 0) + { + throw new ArgumentException(Resources.FormatTemplateRoute_InvalidParameterName(parameterName)); + } + + if (@default != null && parameterKind == RoutePatternParameterKind.Optional) + { + throw new ArgumentNullException(Resources.TemplateRoute_OptionalCannotHaveDefaultValue, nameof(parameterKind)); + } + + return ParameterPartCore( + parameterName: parameterName, + @default: @default, + parameterKind: parameterKind, + constraints: Array.Empty()); + } + + public static RoutePatternParameterPart ParameterPart( + string parameterName, + object @default, + RoutePatternParameterKind parameterKind, + IEnumerable constraints) + { + if (string.IsNullOrEmpty(parameterName)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(parameterName)); + } + + if (parameterName.IndexOfAny(RoutePatternParser.InvalidParameterNameChars) >= 0) + { + throw new ArgumentException(Resources.FormatTemplateRoute_InvalidParameterName(parameterName)); + } + + if (@default != null && parameterKind == RoutePatternParameterKind.Optional) + { + throw new ArgumentNullException(Resources.TemplateRoute_OptionalCannotHaveDefaultValue, nameof(parameterKind)); + } + + if (constraints == null) + { + throw new ArgumentNullException(nameof(constraints)); + } + + return ParameterPartCore( + parameterName: parameterName, + @default: @default, + parameterKind: parameterKind, + constraints: constraints); + } + + public static RoutePatternParameterPart ParameterPart( + string parameterName, + object @default, + RoutePatternParameterKind parameterKind, + params RoutePatternConstraintReference[] constraints) + { + if (string.IsNullOrEmpty(parameterName)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(parameterName)); + } + + if (parameterName.IndexOfAny(RoutePatternParser.InvalidParameterNameChars) >= 0) + { + throw new ArgumentException(Resources.FormatTemplateRoute_InvalidParameterName(parameterName)); + } + + if (@default != null && parameterKind == RoutePatternParameterKind.Optional) + { + throw new ArgumentNullException(Resources.TemplateRoute_OptionalCannotHaveDefaultValue, nameof(parameterKind)); + } + + if (constraints == null) + { + throw new ArgumentNullException(nameof(constraints)); + } + + return ParameterPartCore( + parameterName: parameterName, + @default: @default, + parameterKind: parameterKind, + constraints: constraints); + } + + private static RoutePatternParameterPart ParameterPartCore( + string parameterName, + object @default, + RoutePatternParameterKind parameterKind, + IEnumerable constraints) + { + return new RoutePatternParameterPart(parameterName, @default, parameterKind, constraints.ToArray()); + } + + public static RoutePatternConstraintReference Constraint(string parameterName, object constraint) + { + // Similar to RouteConstraintBuilder + if (constraint is IRouteConstraint routeConstraint) + { + return ConstraintCore(parameterName, routeConstraint); + } + else if (constraint is string content) + { + return ConstraintCore(parameterName, new RegexRouteConstraint("^(" + content + ")$")); + } + else + { + throw new InvalidOperationException(Resources.FormatConstraintMustBeStringOrConstraint( + parameterName, + constraint, + typeof(IRouteConstraint))); + } + } + + public static RoutePatternConstraintReference Constraint(string parameterName, IRouteConstraint constraint) + { + if (constraint == null) + { + throw new ArgumentNullException(nameof(constraint)); + } + + return ConstraintCore(parameterName, constraint); + } + + public static RoutePatternConstraintReference Constraint(string parameterName, string constraint) + { + if (string.IsNullOrEmpty(constraint)) + { + throw new ArgumentException(Resources.Argument_NullOrEmpty, nameof(constraint)); + } + + return ConstraintCore(parameterName, constraint); + } + + + private static RoutePatternConstraintReference ConstraintCore(string parameterName, IRouteConstraint constraint) + { + return new RoutePatternConstraintReference(parameterName, constraint); + } + + private static RoutePatternConstraintReference ConstraintCore(string parameterName, string constraint) + { + return new RoutePatternConstraintReference(parameterName, constraint); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternLiteralPart.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternLiteralPart.cs new file mode 100644 index 0000000000..60cc9d2a96 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternLiteralPart.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + [DebuggerDisplay("{DebuggerToString()}")] + public sealed class RoutePatternLiteralPart : RoutePatternPart + { + internal RoutePatternLiteralPart(string content) + : base(RoutePatternPartKind.Literal) + { + Debug.Assert(!string.IsNullOrEmpty(content)); + Content = content; + } + + public string Content { get; } + + internal override string DebuggerToString() + { + return Content; + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs new file mode 100644 index 0000000000..9ac6d9b90c --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs @@ -0,0 +1,511 @@ +// 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.Routing.Internal; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Routing +{ + 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 pattern, + RouteValueDictionary defaults) + { + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + RoutePattern = pattern; + Defaults = defaults ?? new RouteValueDictionary(); + + // 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; + } + + var parameter = (RoutePatternParameterPart)part; + if (Defaults.TryGetValue(parameter.Name, out var value)) + { + _hasDefaultValue[i] = true; + _defaultValues[i] = value; + } + } + } + + public RouteValueDictionary Defaults { get; } + + public RoutePattern RoutePattern { get; } + + public bool TryMatch(PathString path, RouteValueDictionary 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 RoutePatternParameterPart 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 = (RoutePatternParameterPart)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 RoutePatternParameterPart 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. + if (pathSegment.Parts[0].IsLiteral) + { + var part = (RoutePatternLiteralPart)pathSegment.Parts[0]; + + if (!stringSegment.Equals(part.Content, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + else + { + var part = (RoutePatternSeparatorPart)pathSegment.Parts[0]; + + if (!stringSegment.Equals(part.Content, 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 = (RoutePatternParameterPart)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, RouteValueDictionary values, StringSegment requestSegment, RoutePatternPathSegment pathSegment) + { + if (pathSegment.IsSimple && pathSegment.Parts[0] is RoutePatternParameterPart 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 = (RoutePatternParameterPart)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, + RouteValueDictionary 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 RoutePatternParameterPart parameter && parameter.IsOptional && + routeSegment.Parts[indexOfLastSegment - 1].IsSeparator) + { + if (MatchComplexSegmentCore(routeSegment, requestSegment, Defaults, values, indexOfLastSegment)) + { + return true; + } + else + { + var separator = (RoutePatternSeparatorPart)routeSegment.Parts[indexOfLastSegment - 1]; + if (requestSegment.EndsWith( + separator.Content, + 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, + RouteValueDictionary 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; + + RoutePatternParameterPart 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 RouteValueDictionary(); + + 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 = (RoutePatternParameterPart)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; + } + + int indexOfLiteral; + if (part.IsLiteral) + { + var literal = (RoutePatternLiteralPart)part; + indexOfLiteral = requestSegment.LastIndexOf( + literal.Content, + startIndex, + StringComparison.OrdinalIgnoreCase); + } + else + { + var literal = (RoutePatternSeparatorPart)part; + indexOfLiteral = requestSegment.LastIndexOf( + literal.Content, + 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 (part is RoutePatternLiteralPart literal && ((indexOfLiteral + literal.Content.Length) != requestSegment.Length)) + { + return false; + } + else if (part is RoutePatternSeparatorPart separator && ((indexOfLiteral + separator.Content.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 + { + if (lastLiteral.IsLiteral) + { + var literal = (RoutePatternLiteralPart)lastLiteral; + parameterStartIndex = newLastIndex + literal.Content.Length; + } + else + { + var separator = (RoutePatternSeparatorPart)lastLiteral; + parameterStartIndex = newLastIndex + separator.Content.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/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterKind.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterKind.cs new file mode 100644 index 0000000000..5a682d4897 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterKind.cs @@ -0,0 +1,12 @@ +// 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.AspNetCore.Routing.Patterns +{ + public enum RoutePatternParameterKind + { + Standard, + Optional, + CatchAll, + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterPart.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterPart.cs new file mode 100644 index 0000000000..0f9c632fb9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParameterPart.cs @@ -0,0 +1,73 @@ +// 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.Text; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + [DebuggerDisplay("{DebuggerToString()}")] + public class RoutePatternParameterPart : RoutePatternPart + { + internal RoutePatternParameterPart( + string parameterName, + object @default, + RoutePatternParameterKind parameterKind, + RoutePatternConstraintReference[] constraints) + : base(RoutePatternPartKind.Parameter) + { + // See #475 - this code should have some asserts, but it can't because of the design of RouteParameterParser. + + Name = parameterName; + Default = @default; + ParameterKind = parameterKind; + Constraints = constraints; + } + + public IReadOnlyList Constraints { get; } + + public object Default { get; } + + public bool IsCatchAll => ParameterKind == RoutePatternParameterKind.CatchAll; + + public bool IsOptional => ParameterKind == RoutePatternParameterKind.Optional; + + public RoutePatternParameterKind ParameterKind { get; } + + public string Name { get; } + + internal override string DebuggerToString() + { + var builder = new StringBuilder(); + builder.Append("{"); + + if (IsCatchAll) + { + builder.Append("*"); + } + + builder.Append(Name); + + foreach (var constraint in Constraints) + { + builder.Append(":"); + builder.Append(constraint.Constraint); + } + + if (Default != null) + { + builder.Append("="); + builder.Append(Default); + } + + if (IsOptional) + { + builder.Append("?"); + } + + builder.Append("}"); + return builder.ToString(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParser.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParser.cs new file mode 100644 index 0000000000..0af65b286c --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternParser.cs @@ -0,0 +1,577 @@ +// 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; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + internal static class RoutePatternParser + { + private const char Separator = '/'; + private const char OpenBrace = '{'; + private const char CloseBrace = '}'; + private const char EqualsSign = '='; + private const char QuestionMark = '?'; + private const char Asterisk = '*'; + private const string PeriodString = "."; + + internal static readonly char[] InvalidParameterNameChars = new char[] + { + Separator, + OpenBrace, + CloseBrace, + QuestionMark, + Asterisk + }; + + public static RoutePattern Parse(string pattern) + { + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + var trimmedPattern = TrimPrefix(pattern); + + var context = new Context(trimmedPattern); + var segments = new List(); + + while (context.MoveNext()) + { + var i = context.Index; + + if (context.Current == Separator) + { + // If we get here is means that there's a consecutive '/' character. + // Templates don't start with a '/' and parsing a segment consumes the separator. + throw new RoutePatternException(pattern, Resources.TemplateRoute_CannotHaveConsecutiveSeparators); + } + + if (!ParseSegment(context, segments)) + { + throw new RoutePatternException(pattern, context.Error); + } + + // A successful parse should always result in us being at the end or at a separator. + Debug.Assert(context.AtEnd() || context.Current == Separator); + + if (context.Index <= i) + { + // This shouldn't happen, but we want to crash if it does. + var message = "Infinite loop detected in the parser. Please open an issue."; + throw new InvalidProgramException(message); + } + } + + if (IsAllValid(context, segments)) + { + return RoutePatternFactory.Pattern(pattern, segments); + } + else + { + throw new RoutePatternException(pattern, context.Error); + } + } + + private static bool ParseSegment(Context context, List segments) + { + Debug.Assert(context != null); + Debug.Assert(segments != null); + + var parts = new List(); + + while (true) + { + var i = context.Index; + + if (context.Current == OpenBrace) + { + if (!context.MoveNext()) + { + // This is a dangling open-brace, which is not allowed + context.Error = Resources.TemplateRoute_MismatchedParameter; + return false; + } + + if (context.Current == OpenBrace) + { + // This is an 'escaped' brace in a literal, like "{{foo" + context.Back(); + if (!ParseLiteral(context, parts)) + { + return false; + } + } + else + { + // This is a parameter + context.Back(); + if (!ParseParameter(context, parts)) + { + return false; + } + } + } + else + { + if (!ParseLiteral(context, parts)) + { + return false; + } + } + + if (context.Current == Separator || context.AtEnd()) + { + // We've reached the end of the segment + break; + } + + if (context.Index <= i) + { + // This shouldn't happen, but we want to crash if it does. + var message = "Infinite loop detected in the parser. Please open an issue."; + throw new InvalidProgramException(message); + } + } + + if (IsSegmentValid(context, parts)) + { + segments.Add(new RoutePatternPathSegment(parts.ToArray())); + return true; + } + else + { + return false; + } + } + + private static bool ParseParameter(Context context, List parts) + { + Debug.Assert(context.Current == OpenBrace); + context.Mark(); + + context.MoveNext(); + + while (true) + { + if (context.Current == OpenBrace) + { + // This is an open brace inside of a parameter, it has to be escaped + if (context.MoveNext()) + { + if (context.Current != OpenBrace) + { + // If we see something like "{p1:regex(^\d{3", we will come here. + context.Error = Resources.TemplateRoute_UnescapedBrace; + return false; + } + } + else + { + // This is a dangling open-brace, which is not allowed + // Example: "{p1:regex(^\d{" + context.Error = Resources.TemplateRoute_MismatchedParameter; + return false; + } + } + else if (context.Current == CloseBrace) + { + // When we encounter Closed brace here, it either means end of the parameter or it is a closed + // brace in the parameter, in that case it needs to be escaped. + // Example: {p1:regex(([}}])\w+}. First pair is escaped one and last marks end of the parameter + if (!context.MoveNext()) + { + // This is the end of the string -and we have a valid parameter + break; + } + + if (context.Current == CloseBrace) + { + // This is an 'escaped' brace in a parameter name + } + else + { + // This is the end of the parameter + break; + } + } + + if (!context.MoveNext()) + { + // This is a dangling open-brace, which is not allowed + context.Error = Resources.TemplateRoute_MismatchedParameter; + return false; + } + } + + var text = context.Capture(); + if (text == "{}") + { + context.Error = Resources.FormatTemplateRoute_InvalidParameterName(string.Empty); + return false; + } + + var inside = text.Substring(1, text.Length - 2); + var decoded = inside.Replace("}}", "}").Replace("{{", "{"); + + // At this point, we need to parse the raw name for inline constraint, + // default values and optional parameters. + var templatePart = RouteParameterParser.ParseRouteParameter(decoded); + + // See #475 - this is here because InlineRouteParameterParser can't return errors + if (decoded.StartsWith("*", StringComparison.Ordinal) && decoded.EndsWith("?", StringComparison.Ordinal)) + { + context.Error = Resources.TemplateRoute_CatchAllCannotBeOptional; + return false; + } + + if (templatePart.IsOptional && templatePart.Default != null) + { + // Cannot be optional and have a default value. + // The only way to declare an optional parameter is to have a ? at the end, + // hence we cannot have both default value and optional parameter within the template. + // A workaround is to add it as a separate entry in the defaults argument. + context.Error = Resources.TemplateRoute_OptionalCannotHaveDefaultValue; + return false; + } + + var parameterName = templatePart.Name; + if (IsValidParameterName(context, parameterName)) + { + parts.Add(templatePart); + return true; + } + else + { + return false; + } + } + + private static bool ParseLiteral(Context context, List parts) + { + context.Mark(); + + while (true) + { + if (context.Current == Separator) + { + // End of the segment + break; + } + else if (context.Current == OpenBrace) + { + if (!context.MoveNext()) + { + // This is a dangling open-brace, which is not allowed + context.Error = Resources.TemplateRoute_MismatchedParameter; + return false; + } + + if (context.Current == OpenBrace) + { + // This is an 'escaped' brace in a literal, like "{{foo" - keep going. + } + else + { + // We've just seen the start of a parameter, so back up. + context.Back(); + break; + } + } + else if (context.Current == CloseBrace) + { + if (!context.MoveNext()) + { + // This is a dangling close-brace, which is not allowed + context.Error = Resources.TemplateRoute_MismatchedParameter; + return false; + } + + if (context.Current == CloseBrace) + { + // This is an 'escaped' brace in a literal, like "{{foo" - keep going. + } + else + { + // This is an unbalanced close-brace, which is not allowed + context.Error = Resources.TemplateRoute_MismatchedParameter; + return false; + } + } + + if (!context.MoveNext()) + { + break; + } + } + + var encoded = context.Capture(); + var decoded = encoded.Replace("}}", "}").Replace("{{", "{"); + if (IsValidLiteral(context, decoded)) + { + parts.Add(RoutePatternFactory.LiteralPart(decoded)); + return true; + } + else + { + return false; + } + } + + private static bool IsAllValid(Context context, List segments) + { + // A catch-all parameter must be the last part of the last segment + for (var i = 0; i < segments.Count; i++) + { + var segment = segments[i]; + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + if (part is RoutePatternParameterPart parameter + && parameter.IsCatchAll && + (i != segments.Count - 1 || j != segment.Parts.Count - 1)) + { + context.Error = Resources.TemplateRoute_CatchAllMustBeLast; + return false; + } + } + } + + return true; + } + + private static bool IsSegmentValid(Context context, List parts) + { + // If a segment has multiple parts, then it can't contain a catch all. + for (var i = 0; i < parts.Count; i++) + { + var part = parts[i]; + if (part is RoutePatternParameterPart parameter && parameter.IsCatchAll && parts.Count > 1) + { + context.Error = Resources.TemplateRoute_CannotHaveCatchAllInMultiSegment; + return false; + } + } + + // if a segment has multiple parts, then only the last one parameter can be optional + // if it is following a optional seperator. + for (var i = 0; i < parts.Count; i++) + { + var part = parts[i]; + + if (part is RoutePatternParameterPart parameter && parameter.IsOptional && parts.Count > 1) + { + // This optional parameter is the last part in the segment + if (i == parts.Count - 1) + { + var previousPart = parts[i - 1]; + + if (!previousPart.IsLiteral && !previousPart.IsSeparator) + { + // The optional parameter is preceded by something that is not a literal or separator + // Example of error message: + // "In the segment '{RouteValue}{param?}', the optional parameter 'param' is preceded + // by an invalid segment '{RouteValue}'. Only a period (.) can precede an optional parameter. + context.Error = string.Format( + Resources.TemplateRoute_OptionalParameterCanbBePrecededByPeriod, + RoutePatternPathSegment.DebuggerToString(parts), + parameter.Name, + parts[i - 1].DebuggerToString()); + + return false; + } + else if (previousPart is RoutePatternLiteralPart literal && literal.Content != PeriodString) + { + // The optional parameter is preceded by a literal other than period. + // Example of error message: + // "In the segment '{RouteValue}-{param?}', the optional parameter 'param' is preceded + // by an invalid segment '-'. Only a period (.) can precede an optional parameter. + context.Error = string.Format( + Resources.TemplateRoute_OptionalParameterCanbBePrecededByPeriod, + RoutePatternPathSegment.DebuggerToString(parts), + parameter.Name, + parts[i - 1].DebuggerToString()); + + return false; + } + + parts[i - 1] = RoutePatternFactory.SeparatorPart(((RoutePatternLiteralPart)previousPart).Content); + } + else + { + // This optional parameter is not the last one in the segment + // Example: + // An optional parameter must be at the end of the segment. In the segment '{RouteValue?})', + // optional parameter 'RouteValue' is followed by ')' + context.Error = string.Format( + Resources.TemplateRoute_OptionalParameterHasTobeTheLast, + RoutePatternPathSegment.DebuggerToString(parts), + parameter.Name, + parts[i + 1].DebuggerToString()); + + return false; + } + } + } + + // A segment cannot contain two consecutive parameters + var isLastSegmentParameter = false; + for (var i = 0; i < parts.Count; i++) + { + var part = parts[i]; + if (part.IsParameter && isLastSegmentParameter) + { + context.Error = Resources.TemplateRoute_CannotHaveConsecutiveParameters; + return false; + } + + isLastSegmentParameter = part.IsParameter; + } + + return true; + } + + private static bool IsValidParameterName(Context context, string parameterName) + { + if (parameterName.Length == 0 || parameterName.IndexOfAny(InvalidParameterNameChars) >= 0) + { + context.Error = Resources.FormatTemplateRoute_InvalidParameterName(parameterName); + return false; + } + + if (!context.ParameterNames.Add(parameterName)) + { + context.Error = Resources.FormatTemplateRoute_RepeatedParameter(parameterName); + return false; + } + + return true; + } + + private static bool IsValidLiteral(Context context, string literal) + { + Debug.Assert(context != null); + Debug.Assert(literal != null); + + if (literal.IndexOf(QuestionMark) != -1) + { + context.Error = Resources.FormatTemplateRoute_InvalidLiteral(literal); + return false; + } + + return true; + } + + private static string TrimPrefix(string routePattern) + { + if (routePattern.StartsWith("~/", StringComparison.Ordinal)) + { + return routePattern.Substring(2); + } + else if (routePattern.StartsWith("/", StringComparison.Ordinal)) + { + return routePattern.Substring(1); + } + else if (routePattern.StartsWith("~", StringComparison.Ordinal)) + { + throw new RoutePatternException(routePattern, Resources.TemplateRoute_InvalidRouteTemplate); + } + return routePattern; + } + + [DebuggerDisplay("{DebuggerToString()}")] + private class Context + { + private readonly string _template; + private int _index; + private int? _mark; + + private readonly HashSet _parameterNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + public Context(string template) + { + Debug.Assert(template != null); + _template = template; + + _index = -1; + } + + public char Current + { + get { return (_index < _template.Length && _index >= 0) ? _template[_index] : (char)0; } + } + + public int Index => _index; + + public string Error + { + get; + set; + } + + public HashSet ParameterNames + { + get { return _parameterNames; } + } + + public bool Back() + { + return --_index >= 0; + } + + public bool AtEnd() + { + return _index >= _template.Length; + } + + public bool MoveNext() + { + return ++_index < _template.Length; + } + + public void Mark() + { + Debug.Assert(_index >= 0); + + // Index is always the index of the character *past* Current - we want to 'mark' Current. + _mark = _index; + } + + public string Capture() + { + if (_mark.HasValue) + { + var value = _template.Substring(_mark.Value, _index - _mark.Value); + _mark = null; + return value; + } + else + { + return null; + } + } + + private string DebuggerToString() + { + if (_index == -1) + { + return _template; + } + else if (_mark.HasValue) + { + return _template.Substring(0, _mark.Value) + + "|" + + _template.Substring(_mark.Value, _index - _mark.Value) + + "|" + + _template.Substring(_index); + } + else + { + return _template.Substring(0, _index) + "|" + _template.Substring(_index); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPart.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPart.cs new file mode 100644 index 0000000000..7ea3865699 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPart.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.AspNetCore.Routing.Patterns +{ + public abstract class RoutePatternPart + { + // This class is **not** an extensibility point - every part of the routing system + // needs to be aware of what kind of parts we support. + // + // It is abstract so we can add semantics later inside the library. + private protected RoutePatternPart(RoutePatternPartKind partKind) + { + PartKind = partKind; + } + + public RoutePatternPartKind PartKind { get; } + + public bool IsLiteral => PartKind == RoutePatternPartKind.Literal; + + public bool IsParameter => PartKind == RoutePatternPartKind.Parameter; + + public bool IsSeparator => PartKind == RoutePatternPartKind.Separator; + + internal abstract string DebuggerToString(); + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPartKind.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPartKind.cs new file mode 100644 index 0000000000..1dfaad0250 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPartKind.cs @@ -0,0 +1,12 @@ +// 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.AspNetCore.Routing.Patterns +{ + public enum RoutePatternPartKind + { + Literal, + Parameter, + Separator, + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPathSegment.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPathSegment.cs new file mode 100644 index 0000000000..96c2a86592 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternPathSegment.cs @@ -0,0 +1,32 @@ +// 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; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + [DebuggerDisplay("{DebuggerToString()}")] + public sealed class RoutePatternPathSegment + { + internal RoutePatternPathSegment(RoutePatternPart[] parts) + { + Parts = parts; + } + + public bool IsSimple => Parts.Count == 1; + + public IReadOnlyList Parts { get; } + + internal string DebuggerToString() + { + return DebuggerToString(Parts); + } + + internal static string DebuggerToString(IReadOnlyList parts) + { + return string.Join(string.Empty, parts.Select(p => p.DebuggerToString())); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternSeparatorPart.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternSeparatorPart.cs new file mode 100644 index 0000000000..19333a9467 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternSeparatorPart.cs @@ -0,0 +1,26 @@ +// 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.Diagnostics; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + [DebuggerDisplay("{DebuggerToString()}")] + public sealed class RoutePatternSeparatorPart : RoutePatternPart + { + internal RoutePatternSeparatorPart(string content) + : base(RoutePatternPartKind.Separator) + { + Debug.Assert(!string.IsNullOrEmpty(content)); + + Content = content; + } + + public string Content { get; } + + internal override string DebuggerToString() + { + return Content; + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs index 9384008378..b3f0ee2d2e 100644 --- a/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs @@ -416,6 +416,48 @@ namespace Microsoft.AspNetCore.Routing internal static string FormatAmbiguousEndpoints(object p0, object p1) => string.Format(CultureInfo.CurrentCulture, GetString("AmbiguousEndpoints"), p0, p1); + /// + /// Value cannot be null or empty. + /// + internal static string Argument_NullOrEmpty + { + get => GetString("Argument_NullOrEmpty"); + } + + /// + /// Value cannot be null or empty. + /// + internal static string FormatArgument_NullOrEmpty() + => GetString("Argument_NullOrEmpty"); + + /// + /// The collection cannot be empty. + /// + internal static string RoutePatternBuilder_CollectionCannotBeEmpty + { + get => GetString("RoutePatternBuilder_CollectionCannotBeEmpty"); + } + + /// + /// The collection cannot be empty. + /// + internal static string FormatRoutePatternBuilder_CollectionCannotBeEmpty() + => GetString("RoutePatternBuilder_CollectionCannotBeEmpty"); + + /// + /// The constraint entry '{0}' - '{1}' must have a string value or be of a type which implements '{2}'. + /// + internal static string ConstraintMustBeStringOrConstraint + { + get => GetString("ConstraintMustBeStringOrConstraint"); + } + + /// + /// The constraint entry '{0}' - '{1}' must have a string value or be of a type which implements '{2}'. + /// + internal static string FormatConstraintMustBeStringOrConstraint(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("ConstraintMustBeStringOrConstraint"), p0, p1, p2); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Routing/Resources.resx b/src/Microsoft.AspNetCore.Routing/Resources.resx index 354bdfda0e..f5db3ec8d0 100644 --- a/src/Microsoft.AspNetCore.Routing/Resources.resx +++ b/src/Microsoft.AspNetCore.Routing/Resources.resx @@ -204,4 +204,13 @@ The request matched multiple endpoints. Matches: {0}{0}{1} + + Value cannot be null or empty. + + + The collection cannot be empty. + + + The constraint entry '{0}' - '{1}' must have a string value or be of a type which implements '{2}'. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/RouteCollection.cs b/src/Microsoft.AspNetCore.Routing/RouteCollection.cs index b25748552c..512107274e 100644 --- a/src/Microsoft.AspNetCore.Routing/RouteCollection.cs +++ b/src/Microsoft.AspNetCore.Routing/RouteCollection.cs @@ -161,7 +161,7 @@ namespace Microsoft.AspNetCore.Routing queryString = queryString.ToLowerInvariant(); } - if (_options.AppendTrailingSlash && !urlWithoutQueryString.EndsWith("/")) + if (_options.AppendTrailingSlash && !urlWithoutQueryString.EndsWith("/", StringComparison.Ordinal)) { urlWithoutQueryString += "/"; } diff --git a/src/Microsoft.AspNetCore.Routing/Template/InlineConstraint.cs b/src/Microsoft.AspNetCore.Routing/Template/InlineConstraint.cs index 5c50eefb0e..6b17cf88c5 100644 --- a/src/Microsoft.AspNetCore.Routing/Template/InlineConstraint.cs +++ b/src/Microsoft.AspNetCore.Routing/Template/InlineConstraint.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing.Template { @@ -24,6 +25,16 @@ namespace Microsoft.AspNetCore.Routing.Template Constraint = constraint; } + public InlineConstraint(RoutePatternConstraintReference other) + { + if (other == null) + { + throw new ArgumentNullException(nameof(other)); + } + + Constraint = other.Content; + } + /// /// Gets the constraint text. /// diff --git a/src/Microsoft.AspNetCore.Routing/Template/RouteTemplate.cs b/src/Microsoft.AspNetCore.Routing/Template/RouteTemplate.cs index d482f3a1b4..ff41bf6af7 100644 --- a/src/Microsoft.AspNetCore.Routing/Template/RouteTemplate.cs +++ b/src/Microsoft.AspNetCore.Routing/Template/RouteTemplate.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing.Template { @@ -13,6 +14,25 @@ namespace Microsoft.AspNetCore.Routing.Template { private const string SeparatorString = "/"; + public RouteTemplate(RoutePattern other) + { + TemplateText = other.RawText; + Segments = new List(other.PathSegments.Select(p => new TemplateSegment(p))); + Parameters = new List(); + for (var i = 0; i < Segments.Count; i++) + { + var segment = Segments[i]; + for (var j = 0; j < segment.Parts.Count; j++) + { + var part = segment.Parts[j]; + if (part.IsParameter) + { + Parameters.Add(part); + } + } + } + } + public RouteTemplate(string template, List segments) { if (segments == null) @@ -78,5 +98,16 @@ namespace Microsoft.AspNetCore.Routing.Template return null; } + + /// + /// Converts the to the equivalent + /// + /// + /// A . + public RoutePattern ToRoutePattern() + { + var segments = Segments.Select(s => s.ToRoutePatternPathSegment()); + return RoutePatternFactory.Pattern(TemplateText, segments); + } } } diff --git a/src/Microsoft.AspNetCore.Routing/Template/TemplateMatcher.cs b/src/Microsoft.AspNetCore.Routing/Template/TemplateMatcher.cs index e7cc2ab9f5..ebb0a0d779 100644 --- a/src/Microsoft.AspNetCore.Routing/Template/TemplateMatcher.cs +++ b/src/Microsoft.AspNetCore.Routing/Template/TemplateMatcher.cs @@ -2,10 +2,8 @@ // 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.Routing; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.Internal; namespace Microsoft.AspNetCore.Routing.Template { @@ -19,6 +17,7 @@ namespace Microsoft.AspNetCore.Routing.Template private readonly object[] _defaultValues; private static readonly char[] Delimiters = new char[] { SeparatorChar }; + private RoutePatternMatcher _routePatternMatcher; public TemplateMatcher( RouteTemplate template, @@ -57,6 +56,9 @@ namespace Microsoft.AspNetCore.Routing.Template _defaultValues[i] = value; } } + + var routePattern = Template.ToRoutePattern(); + _routePatternMatcher = new RoutePatternMatcher(routePattern, Defaults); } public RouteValueDictionary Defaults { get; } @@ -70,387 +72,7 @@ namespace Microsoft.AspNetCore.Routing.Template 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 pathSegment in pathTokenizer) - { - if (pathSegment.Length == 0) - { - return false; - } - - var routeSegment = Template.GetSegment(i++); - if (routeSegment == null && pathSegment.Length > 0) - { - // If routeSegment is null, then we're out of route segments. All we can match is the empty - // string. - return false; - } - else if (routeSegment.IsSimple && routeSegment.Parts[0].IsLiteral) - { - // This is a literal segment, so we need to match the text, or the route isn't a match. - var part = routeSegment.Parts[0]; - if (!pathSegment.Equals(part.Text, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - else if (routeSegment.IsSimple && routeSegment.Parts[0].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; - } - else if (routeSegment.IsSimple && routeSegment.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 = routeSegment.Parts[0]; - if (pathSegment.Length == 0 && - !_hasDefaultValue[i] && - !part.IsOptional) - { - // There's no value for this parameter, the route can't match. - return false; - } - } - else - { - Debug.Assert(!routeSegment.IsSimple); - - // Don't attempt to validate a complex segment at this point other than being non-emtpy, - // do it in the second pass. - } - } - - for (; i < Template.Segments.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 routeSegment = Template.GetSegment(i); - Debug.Assert(routeSegment != null); - - if (!routeSegment.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 = routeSegment.Parts[0]; - if (part.IsLiteral) - { - // If the segment is a simple literal - which need the URL to provide a value, so we don't match. - return false; - } - - if (part.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. - Debug.Assert(routeSegment.IsSimple && part.IsParameter); - if (!_hasDefaultValue[i] && !part.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 routeSegment = Template.GetSegment(i++); - - if (routeSegment.IsSimple && routeSegment.Parts[0].IsCatchAll) - { - // A catch-all captures til the end of the string. - var part = routeSegment.Parts[0]; - var captured = requestSegment.Buffer.Substring(requestSegment.Offset); - if (captured.Length > 0) - { - values[part.Name] = captured; - } - else - { - // It's ok for a catch-all to produce a null value, so we don't check _hasDefaultValue. - values[part.Name] = _defaultValues[i]; - } - - // A catch-all has to be the last part, so we're done. - break; - } - else if (routeSegment.IsSimple && routeSegment.Parts[0].IsParameter) - { - // A simple parameter captures the whole segment, or a default value if nothing was - // provided. - var part = routeSegment.Parts[0]; - if (requestSegment.Length > 0) - { - values[part.Name] = requestSegment.ToString(); - } - else - { - if (_hasDefaultValue[i]) - { - values[part.Name] = _defaultValues[i]; - } - } - } - else if (!routeSegment.IsSimple) - { - if (!MatchComplexSegment(routeSegment, requestSegment.ToString(), Defaults, values)) - { - return false; - } - } - } - - for (; i < Template.Segments.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 routeSegment = Template.GetSegment(i); - Debug.Assert(routeSegment != null); - Debug.Assert(routeSegment.IsSimple); - - var part = routeSegment.Parts[0]; - Debug.Assert(part.IsParameter); - - // It's ok for a catch-all to produce a null value - if (_hasDefaultValue[i] || part.IsCatchAll) - { - // Don't replace an existing value with a null. - var defaultValue = _defaultValues[i]; - if (defaultValue != null || !values.ContainsKey(part.Name)) - { - values[part.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 MatchComplexSegment( - TemplateSegment routeSegment, - string requestSegment, - IReadOnlyDictionary defaults, - RouteValueDictionary 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].IsOptional && - routeSegment.Parts[indexOfLastSegment - 1].IsOptionalSeperator) - { - if (MatchComplexSegmentCore(routeSegment, requestSegment, Defaults, values, indexOfLastSegment)) - { - return true; - } - else - { - if (requestSegment.EndsWith( - routeSegment.Parts[indexOfLastSegment - 1].Text, - StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return MatchComplexSegmentCore( - routeSegment, - requestSegment, - Defaults, - values, - indexOfLastSegment - 2); - } - } - else - { - return MatchComplexSegmentCore(routeSegment, requestSegment, Defaults, values, indexOfLastSegment); - } - } - - private bool MatchComplexSegmentCore( - TemplateSegment routeSegment, - string requestSegment, - IReadOnlyDictionary defaults, - RouteValueDictionary 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; - - TemplatePart parameterNeedsValue = null; // Keeps track of a parameter segment that is pending a value - TemplatePart lastLiteral = null; // Keeps track of the left-most literal we've encountered - - var outValues = new RouteValueDictionary(); - - 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 = part; - } - else - { - Debug.Assert(part.IsLiteral); - 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.Text, - 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.Text.Length) != requestSegment.Length) - { - return false; - } - } - - newLastIndex = indexOfLiteral; - } - - if ((parameterNeedsValue != null) && - (((lastLiteral != null) && (part.IsLiteral)) || (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.Text.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; + return _routePatternMatcher.TryMatch(path, values); } } } diff --git a/src/Microsoft.AspNetCore.Routing/Template/TemplateParser.cs b/src/Microsoft.AspNetCore.Routing/Template/TemplateParser.cs index 0168b22a4b..ad1afe86e6 100644 --- a/src/Microsoft.AspNetCore.Routing/Template/TemplateParser.cs +++ b/src/Microsoft.AspNetCore.Routing/Template/TemplateParser.cs @@ -1,539 +1,29 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; +using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing.Template { public static class TemplateParser { - private const char Separator = '/'; - private const char OpenBrace = '{'; - private const char CloseBrace = '}'; - private const char EqualsSign = '='; - private const char QuestionMark = '?'; - private const char Asterisk = '*'; - private const string PeriodString = "."; - public static RouteTemplate Parse(string routeTemplate) { if (routeTemplate == null) { - routeTemplate = String.Empty; + throw new ArgumentNullException(routeTemplate); } - var trimmedRouteTemplate = TrimPrefix(routeTemplate); - - var context = new TemplateParserContext(trimmedRouteTemplate); - var segments = new List(); - - while (context.Next()) + try { - if (context.Current == Separator) - { - // If we get here is means that there's a consecutive '/' character. - // Templates don't start with a '/' and parsing a segment consumes the separator. - throw new ArgumentException(Resources.TemplateRoute_CannotHaveConsecutiveSeparators, - nameof(routeTemplate)); - } - else - { - if (!ParseSegment(context, segments)) - { - throw new ArgumentException(context.Error, nameof(routeTemplate)); - } - } + var inner = RoutePatternFactory.Parse(routeTemplate); + return new RouteTemplate(inner); } - - if (IsAllValid(context, segments)) + catch (RoutePatternException ex) { - return new RouteTemplate(routeTemplate, segments); - } - else - { - throw new ArgumentException(context.Error, nameof(routeTemplate)); - } - } - - private static string TrimPrefix(string routeTemplate) - { - if (routeTemplate.StartsWith("~/", StringComparison.Ordinal)) - { - return routeTemplate.Substring(2); - } - else if (routeTemplate.StartsWith("/", StringComparison.Ordinal)) - { - return routeTemplate.Substring(1); - } - else if (routeTemplate.StartsWith("~", StringComparison.Ordinal)) - { - throw new ArgumentException(Resources.TemplateRoute_InvalidRouteTemplate, nameof(routeTemplate)); - } - return routeTemplate; - } - - private static bool ParseSegment(TemplateParserContext context, List segments) - { - Debug.Assert(context != null); - Debug.Assert(segments != null); - - var segment = new TemplateSegment(); - - while (true) - { - if (context.Current == OpenBrace) - { - if (!context.Next()) - { - // This is a dangling open-brace, which is not allowed - context.Error = Resources.TemplateRoute_MismatchedParameter; - return false; - } - - if (context.Current == OpenBrace) - { - // This is an 'escaped' brace in a literal, like "{{foo" - context.Back(); - if (!ParseLiteral(context, segment)) - { - return false; - } - } - else - { - // This is the inside of a parameter - if (!ParseParameter(context, segment)) - { - return false; - } - } - } - else if (context.Current == Separator) - { - // We've reached the end of the segment - break; - } - else - { - if (!ParseLiteral(context, segment)) - { - return false; - } - } - - if (!context.Next()) - { - // We've reached the end of the string - break; - } - } - - if (IsSegmentValid(context, segment)) - { - segments.Add(segment); - return true; - } - else - { - return false; - } - } - - private static bool ParseParameter(TemplateParserContext context, TemplateSegment segment) - { - context.Mark(); - - while (true) - { - if (context.Current == OpenBrace) - { - // This is an open brace inside of a parameter, it has to be escaped - if (context.Next()) - { - if (context.Current != OpenBrace) - { - // If we see something like "{p1:regex(^\d{3", we will come here. - context.Error = Resources.TemplateRoute_UnescapedBrace; - return false; - } - } - else - { - // This is a dangling open-brace, which is not allowed - // Example: "{p1:regex(^\d{" - context.Error = Resources.TemplateRoute_MismatchedParameter; - return false; - } - } - else if (context.Current == CloseBrace) - { - // When we encounter Closed brace here, it either means end of the parameter or it is a closed - // brace in the parameter, in that case it needs to be escaped. - // Example: {p1:regex(([}}])\w+}. First pair is escaped one and last marks end of the parameter - if (!context.Next()) - { - // This is the end of the string -and we have a valid parameter - context.Back(); - break; - } - - if (context.Current == CloseBrace) - { - // This is an 'escaped' brace in a parameter name - } - else - { - // This is the end of the parameter - context.Back(); - break; - } - } - - if (!context.Next()) - { - // This is a dangling open-brace, which is not allowed - context.Error = Resources.TemplateRoute_MismatchedParameter; - return false; - } - } - - var rawParameter = context.Capture(); - var decoded = rawParameter.Replace("}}", "}").Replace("{{", "{"); - - // At this point, we need to parse the raw name for inline constraint, - // default values and optional parameters. - var templatePart = InlineRouteParameterParser.ParseRouteParameter(decoded); - - if (templatePart.IsCatchAll && templatePart.IsOptional) - { - context.Error = Resources.TemplateRoute_CatchAllCannotBeOptional; - return false; - } - - if (templatePart.IsOptional && templatePart.DefaultValue != null) - { - // Cannot be optional and have a default value. - // The only way to declare an optional parameter is to have a ? at the end, - // hence we cannot have both default value and optional parameter within the template. - // A workaround is to add it as a separate entry in the defaults argument. - context.Error = Resources.TemplateRoute_OptionalCannotHaveDefaultValue; - return false; - } - - var parameterName = templatePart.Name; - if (IsValidParameterName(context, parameterName)) - { - segment.Parts.Add(templatePart); - return true; - } - else - { - return false; - } - } - - private static bool ParseLiteral(TemplateParserContext context, TemplateSegment segment) - { - context.Mark(); - - string encoded; - while (true) - { - if (context.Current == Separator) - { - encoded = context.Capture(); - context.Back(); - break; - } - else if (context.Current == OpenBrace) - { - if (!context.Next()) - { - // This is a dangling open-brace, which is not allowed - context.Error = Resources.TemplateRoute_MismatchedParameter; - return false; - } - - if (context.Current == OpenBrace) - { - // This is an 'escaped' brace in a literal, like "{{foo" - keep going. - } - else - { - // We've just seen the start of a parameter, so back up and return - context.Back(); - encoded = context.Capture(); - context.Back(); - break; - } - } - else if (context.Current == CloseBrace) - { - if (!context.Next()) - { - // This is a dangling close-brace, which is not allowed - context.Error = Resources.TemplateRoute_MismatchedParameter; - return false; - } - - if (context.Current == CloseBrace) - { - // This is an 'escaped' brace in a literal, like "{{foo" - keep going. - } - else - { - // This is an unbalanced close-brace, which is not allowed - context.Error = Resources.TemplateRoute_MismatchedParameter; - return false; - } - } - - if (!context.Next()) - { - encoded = context.Capture(); - break; - } - } - - var decoded = encoded.Replace("}}", "}").Replace("{{", "{"); - if (IsValidLiteral(context, decoded)) - { - segment.Parts.Add(TemplatePart.CreateLiteral(decoded)); - return true; - } - else - { - return false; - } - } - - private static bool IsAllValid(TemplateParserContext context, List segments) - { - // A catch-all parameter must be the last part of the last segment - for (var i = 0; i < segments.Count; i++) - { - var segment = segments[i]; - for (var j = 0; j < segment.Parts.Count; j++) - { - var part = segment.Parts[j]; - if (part.IsParameter && - part.IsCatchAll && - (i != segments.Count - 1 || j != segment.Parts.Count - 1)) - { - context.Error = Resources.TemplateRoute_CatchAllMustBeLast; - return false; - } - } - } - - return true; - } - - private static bool IsSegmentValid(TemplateParserContext context, TemplateSegment segment) - { - // If a segment has multiple parts, then it can't contain a catch all. - for (var i = 0; i < segment.Parts.Count; i++) - { - var part = segment.Parts[i]; - if (part.IsParameter && part.IsCatchAll && segment.Parts.Count > 1) - { - context.Error = Resources.TemplateRoute_CannotHaveCatchAllInMultiSegment; - return false; - } - } - - // if a segment has multiple parts, then only the last one parameter can be optional - // if it is following a optional seperator. - for (var i = 0; i < segment.Parts.Count; i++) - { - var part = segment.Parts[i]; - - if (part.IsParameter && part.IsOptional && segment.Parts.Count > 1) - { - // This optional parameter is the last part in the segment - if (i == segment.Parts.Count - 1) - { - if (!segment.Parts[i - 1].IsLiteral) - { - // The optional parameter is preceded by something that is not a literal. - // Example of error message: - // "In the segment '{RouteValue}{param?}', the optional parameter 'param' is preceded - // by an invalid segment '{RouteValue}'. Only a period (.) can precede an optional parameter. - context.Error = string.Format( - Resources.TemplateRoute_OptionalParameterCanbBePrecededByPeriod, - segment.DebuggerToString(), - part.Name, - segment.Parts[i - 1].DebuggerToString()); - - return false; - } - else if (segment.Parts[i - 1].Text != PeriodString) - { - // The optional parameter is preceded by a literal other than period. - // Example of error message: - // "In the segment '{RouteValue}-{param?}', the optional parameter 'param' is preceded - // by an invalid segment '-'. Only a period (.) can precede an optional parameter. - context.Error = string.Format( - Resources.TemplateRoute_OptionalParameterCanbBePrecededByPeriod, - segment.DebuggerToString(), - part.Name, - segment.Parts[i - 1].Text); - - return false; - } - - segment.Parts[i - 1].IsOptionalSeperator = true; - } - else - { - // This optional parameter is not the last one in the segment - // Example: - // An optional parameter must be at the end of the segment.In the segment '{RouteValue?})', - // optional parameter 'RouteValue' is followed by ')' - var nextPart = segment.Parts[i + 1]; - var invalidPartText = nextPart.IsParameter ? nextPart.Name : nextPart.Text; - - context.Error = string.Format( - Resources.TemplateRoute_OptionalParameterHasTobeTheLast, - segment.DebuggerToString(), - segment.Parts[i].Name, - invalidPartText); - - return false; - } - } - } - - // A segment cannot contain two consecutive parameters - var isLastSegmentParameter = false; - for (var i = 0; i < segment.Parts.Count; i++) - { - var part = segment.Parts[i]; - if (part.IsParameter && isLastSegmentParameter) - { - context.Error = Resources.TemplateRoute_CannotHaveConsecutiveParameters; - return false; - } - - isLastSegmentParameter = part.IsParameter; - } - - return true; - } - - private static bool IsValidParameterName(TemplateParserContext context, string parameterName) - { - if (parameterName.Length == 0) - { - context.Error = String.Format(CultureInfo.CurrentCulture, - Resources.TemplateRoute_InvalidParameterName, parameterName); - return false; - } - - for (var i = 0; i < parameterName.Length; i++) - { - var c = parameterName[i]; - if (c == Separator || c == OpenBrace || c == CloseBrace || c == QuestionMark || c == Asterisk) - { - context.Error = String.Format(CultureInfo.CurrentCulture, - Resources.TemplateRoute_InvalidParameterName, parameterName); - return false; - } - } - - if (!context.ParameterNames.Add(parameterName)) - { - context.Error = String.Format(CultureInfo.CurrentCulture, - Resources.TemplateRoute_RepeatedParameter, parameterName); - return false; - } - - return true; - } - - private static bool IsValidLiteral(TemplateParserContext context, string literal) - { - Debug.Assert(context != null); - Debug.Assert(literal != null); - - if (literal.IndexOf(QuestionMark) != -1) - { - context.Error = String.Format(CultureInfo.CurrentCulture, - Resources.TemplateRoute_InvalidLiteral, literal); - return false; - } - - return true; - } - - private static bool IsInvalidRouteTemplate(string routeTemplate) - { - return routeTemplate.StartsWith("~", StringComparison.Ordinal) || - routeTemplate.StartsWith("/", StringComparison.Ordinal); - } - - private class TemplateParserContext - { - private readonly string _template; - private int _index; - private int? _mark; - - private HashSet _parameterNames = new HashSet(StringComparer.OrdinalIgnoreCase); - - public TemplateParserContext(string template) - { - Debug.Assert(template != null); - _template = template; - - _index = -1; - } - - public char Current - { - get { return (_index < _template.Length && _index >= 0) ? _template[_index] : (char)0; } - } - - public string Error - { - get; - set; - } - - public HashSet ParameterNames - { - get { return _parameterNames; } - } - - public bool Back() - { - return --_index >= 0; - } - - public bool Next() - { - return ++_index < _template.Length; - } - - public void Mark() - { - _mark = _index; - } - - public string Capture() - { - if (_mark.HasValue) - { - var value = _template.Substring(_mark.Value, _index - _mark.Value); - _mark = null; - return value; - } - else - { - return null; - } + // Preserving the existing behavior of this API even though the logic moved. + throw new ArgumentException(ex.Message, nameof(routeTemplate), ex); } } } diff --git a/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs b/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs index 70f588a41a..c8ba0b319c 100644 --- a/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs +++ b/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs @@ -5,12 +5,47 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing.Template { [DebuggerDisplay("{DebuggerToString()}")] public class TemplatePart { + public TemplatePart() + { + } + + public TemplatePart(RoutePatternPart other) + { + IsLiteral = other.IsLiteral || other.IsSeparator; + IsParameter = other.IsParameter; + + if (other.IsLiteral && other is RoutePatternLiteralPart literal) + { + Text = literal.Content; + } + else if (other.IsParameter && other is RoutePatternParameterPart parameter) + { + // Text is unused by TemplatePart and assumed to be null when the part is a parameter. + Name = parameter.Name; + IsCatchAll = parameter.IsCatchAll; + IsOptional = parameter.IsOptional; + DefaultValue = parameter.Default; + InlineConstraints = parameter.Constraints?.Select(p => new InlineConstraint(p)); + } + else if (other.IsSeparator && other is RoutePatternSeparatorPart separator) + { + Text = separator.Content; + IsOptionalSeperator = true; + } + else + { + // Unreachable + throw new NotSupportedException(); + } + } + public static TemplatePart CreateLiteral(string text) { return new TemplatePart() @@ -20,11 +55,12 @@ namespace Microsoft.AspNetCore.Routing.Template }; } - public static TemplatePart CreateParameter(string name, - bool isCatchAll, - bool isOptional, - object defaultValue, - IEnumerable inlineConstraints) + public static TemplatePart CreateParameter( + string name, + bool isCatchAll, + bool isOptional, + object defaultValue, + IEnumerable inlineConstraints) { if (name == null) { @@ -63,5 +99,28 @@ namespace Microsoft.AspNetCore.Routing.Template return Text; } } + + public RoutePatternPart ToRoutePatternPart() + { + if (IsLiteral && IsOptionalSeperator) + { + return RoutePatternFactory.SeparatorPart(Text); + } + else if (IsLiteral) + { + return RoutePatternFactory.LiteralPart(Text); + } + else + { + var kind = IsCatchAll ? + RoutePatternParameterKind.CatchAll : + IsOptional ? + RoutePatternParameterKind.Optional : + RoutePatternParameterKind.Standard; + + var constraints = InlineConstraints.Select(c => new RoutePatternConstraintReference(Name, c.Constraint)); + return RoutePatternFactory.ParameterPart(Name, DefaultValue, kind, constraints); + } + } } } diff --git a/src/Microsoft.AspNetCore.Routing/Template/TemplateSegment.cs b/src/Microsoft.AspNetCore.Routing/Template/TemplateSegment.cs index 4a86526509..a3aec42686 100644 --- a/src/Microsoft.AspNetCore.Routing/Template/TemplateSegment.cs +++ b/src/Microsoft.AspNetCore.Routing/Template/TemplateSegment.cs @@ -4,19 +4,36 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using Microsoft.AspNetCore.Routing.Patterns; namespace Microsoft.AspNetCore.Routing.Template { [DebuggerDisplay("{DebuggerToString()}")] public class TemplateSegment { + public TemplateSegment() + { + Parts = new List(); + } + + public TemplateSegment(RoutePatternPathSegment other) + { + Parts = new List(other.Parts.Select(s => new TemplatePart(s))); + } + public bool IsSimple => Parts.Count == 1; - public List Parts { get; } = new List(); + public List Parts { get; } internal string DebuggerToString() { return string.Join(string.Empty, Parts.Select(p => p.DebuggerToString())); } + + public RoutePatternPathSegment ToRoutePatternPathSegment() + { + var parts = Parts.Select(p => p.ToRoutePatternPart()); + return RoutePatternFactory.Segment(parts); + } } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Patterns/InlineRouteParameterParserTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/InlineRouteParameterParserTest.cs new file mode 100644 index 0000000000..87ab942b23 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/InlineRouteParameterParserTest.cs @@ -0,0 +1,946 @@ +// 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.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + public class InlineRouteParameterParserTest + { + [Theory] + [InlineData("=")] + [InlineData(":")] + public void ParseRouteParameter_WithoutADefaultValue(string parameterName) + { + // Arrange & Act + var templatePart = ParseParameter(parameterName); + + // Assert + Assert.Equal(parameterName, templatePart.Name); + Assert.Null(templatePart.Default); + Assert.Empty(templatePart.Constraints); + } + + [Fact] + public void ParseRouteParameter_WithEmptyDefaultValue() + { + // Arrange & Act + var templatePart = ParseParameter("param="); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("", templatePart.Default); + Assert.Empty(templatePart.Constraints); + } + + [Fact] + public void ParseRouteParameter_WithoutAConstraintName() + { + // Arrange & Act + var templatePart = ParseParameter("param:"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.Empty(templatePart.Constraints); + } + + [Fact] + public void ParseRouteParameter_WithoutAConstraintNameOrParameterName() + { + // Arrange & Act + var templatePart = ParseParameter("param:="); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("", templatePart.Default); + Assert.Empty(templatePart.Constraints); + } + + [Fact] + public void ParseRouteParameter_WithADefaultValueContainingConstraintSeparator() + { + // Arrange & Act + var templatePart = ParseParameter("param=:"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal(":", templatePart.Default); + Assert.Empty(templatePart.Constraints); + } + + [Fact] + public void ParseRouteParameter_ConstraintAndDefault_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter("param:int=111111"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("111111", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("int", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithArgumentsAndDefault_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\d+)=111111"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("111111", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\d+)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintAndOptional_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:int?"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.True(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("int", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintAndOptional_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:int=12?"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("12", templatePart.Default); + Assert.True(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("int", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintAndOptional_WithDefaultValueWithQuestionMark_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:int=12??"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("12?", templatePart.Default); + Assert.True(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("int", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithArgumentsAndOptional_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\d+)?"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.True(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\d+)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithArgumentsAndOptional_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\d+)=abc?"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.True(templatePart.IsOptional); + + Assert.Equal("abc", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\d+)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ChainedConstraints_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(d+):test(w+)"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Collection(templatePart.Constraints, + constraint => Assert.Equal(@"test(d+)", constraint.Content), + constraint => Assert.Equal(@"test(w+)", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ChainedConstraints_DoubleDelimiters_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param::test(d+)::test(w+)"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Collection( + templatePart.Constraints, + constraint => Assert.Equal(@"test(d+)", constraint.Content), + constraint => Assert.Equal(@"test(w+)", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ChainedConstraints_ColonInPattern_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\d+):test(\w:+)"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Collection(templatePart.Constraints, + constraint => Assert.Equal(@"test(\d+)", constraint.Content), + constraint => Assert.Equal(@"test(\w:+)", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ChainedConstraints_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\d+):test(\w+)=qwer"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("qwer", templatePart.Default); + + Assert.Collection(templatePart.Constraints, + constraint => Assert.Equal(@"test(\d+)", constraint.Content), + constraint => Assert.Equal(@"test(\w+)", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ChainedConstraints_WithDefaultValue_DoubleDelimiters_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\d+)::test(\w+)==qwer"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("=qwer", templatePart.Default); + + Assert.Collection( + templatePart.Constraints, + constraint => Assert.Equal(@"test(\d+)", constraint.Content), + constraint => Assert.Equal(@"test(\w+)", constraint.Content)); + } + + [Theory] + [InlineData("=")] + [InlineData("+=")] + [InlineData(">= || <= || ==")] + public void ParseRouteParameter_WithDefaultValue_ContainingDelimiter(string defaultValue) + { + // Arrange & Act + var templatePart = ParseParameter($"comparison-operator:length(6)={defaultValue}"); + + // Assert + Assert.Equal("comparison-operator", templatePart.Name); + Assert.Equal(defaultValue, templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("length(6)", constraint.Content); + } + + [Fact] + public void ParseRouteTemplate_ConstraintsDefaultsAndOptionalsInMultipleSections_ParsedCorrectly() + { + // Arrange & Act + var routePattern = RoutePatternFactory.Parse(@"some/url-{p1:int:test(3)=hello}/{p2=abc}/{p3?}"); + + // Assert + var parameters = routePattern.Parameters.ToArray(); + + var param1 = parameters[0]; + Assert.Equal("p1", param1.Name); + Assert.Equal("hello", param1.Default); + Assert.False(param1.IsOptional); + + Assert.Collection(param1.Constraints, + constraint => Assert.Equal("int", constraint.Content), + constraint => Assert.Equal("test(3)", constraint.Content) + ); + + var param2 = parameters[1]; + Assert.Equal("p2", param2.Name); + Assert.Equal("abc", param2.Default); + Assert.False(param2.IsOptional); + + var param3 = parameters[2]; + Assert.Equal("p3", param3.Name); + Assert.True(param3.IsOptional); + } + + [Fact] + public void ParseRouteParameter_NoTokens_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter("world"); + + // Assert + Assert.Equal("world", templatePart.Name); + } + + [Fact] + public void ParseRouteParameter_ParamDefault_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter("param=world"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("world", templatePart.Default); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithClosingBraceInPattern_ClosingBraceIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\})"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\})", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithClosingBraceInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\})=wer"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("wer", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\})", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithClosingParenInPattern_ClosingParenIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\))"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\))", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithClosingParenInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\))=fsd"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("fsd", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\))", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithColonInPattern_ColonIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(:)"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(:)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithColonInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(:)=mnf"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("mnf", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(:)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithColonsInPattern_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(a:b:c)"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(a:b:c)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithColonInParamName_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@":param:test=12"); + + // Assert + Assert.Equal(":param", templatePart.Name); + + Assert.Equal("12", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("test", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithTwoColonInParamName_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@":param::test=12"); + + // Assert + Assert.Equal(":param", templatePart.Name); + + Assert.Equal("12", templatePart.Default); + + Assert.Collection( + templatePart.Constraints, + constraint => Assert.Equal("test", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_EmptyConstraint_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@":param:test:"); + + // Assert + Assert.Equal(":param", templatePart.Name); + + Assert.Collection( + templatePart.Constraints, + constraint => Assert.Equal("test", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithCommaInPattern_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\w,\w)"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\w,\w)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithCommaInName_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"par,am:test(\w)"); + + // Assert + Assert.Equal("par,am", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\w)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithCommaInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\w,\w)=jsd"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("jsd", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\w,\w)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithEqualsFollowedByQuestionMark_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:int=?"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("", templatePart.Default); + + Assert.True(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("int", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithEqualsSignInPattern_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(=)"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Null(templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("test(=)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_EqualsSignInDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param=test=bar"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("test=bar", templatePart.Default); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithEqualEqualSignInPattern_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(a==b)"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Null(templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("test(a==b)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithEqualEqualSignInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(a==b)=dvds"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("dvds", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("test(a==b)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_EqualEqualSignInName_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"par==am:test=dvds"); + + // Assert + Assert.Equal("par", templatePart.Name); + Assert.Equal("=am:test=dvds", templatePart.Default); + } + + [Fact] + public void ParseRouteParameter_EqualEqualSignInDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test==dvds"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("=dvds", templatePart.Default); + } + + [Fact] + public void ParseRouteParameter_DefaultValueWithColonAndParens_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"par=am:test(asd)"); + + // Assert + Assert.Equal("par", templatePart.Name); + Assert.Equal("am:test(asd)", templatePart.Default); + } + + [Fact] + public void ParseRouteParameter_DefaultValueWithEqualsSignIn_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"par=test(am):est=asd"); + + // Assert + Assert.Equal("par", templatePart.Name); + Assert.Equal("test(am):est=asd", templatePart.Default); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithEqualsSignInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(=)=sds"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("sds", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("test(=)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenBraceInPattern_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\{)"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\{)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenBraceInName_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"par{am:test(\sd)"); + + // Assert + Assert.Equal("par{am", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\sd)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenBraceInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\{)=xvc"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("xvc", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\{)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenParenInName_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"par(am:test(\()"); + + // Assert + Assert.Equal("par(am", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\()", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenParenInPattern_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\()"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\()", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenParenNoCloseParen_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(#$%"); + + // Assert + Assert.Equal("param", templatePart.Name); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal("test(#$%", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenParenAndColon_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(#:test1"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Collection(templatePart.Constraints, + constraint => Assert.Equal(@"test(#", constraint.Content), + constraint => Assert.Equal(@"test1", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenParenAndColonWithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(abc:somevalue):name(test1:differentname=default-value"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("default-value", templatePart.Default); + + Assert.Collection(templatePart.Constraints, + constraint => Assert.Equal(@"test(abc:somevalue)", constraint.Content), + constraint => Assert.Equal(@"name(test1", constraint.Content), + constraint => Assert.Equal(@"differentname", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenParenAndDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(constraintvalue=test1"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("test1", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(constraintvalue", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithOpenParenInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\()=djk"); + + // Assert + Assert.Equal("param", templatePart.Name); + + Assert.Equal("djk", templatePart.Default); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\()", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithQuestionMarkInPattern_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\?)"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.False(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\?)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithQuestionMarkInPattern_Optional_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\?)?"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.True(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\?)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithQuestionMarkInPattern_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\?)=sdf"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("sdf", templatePart.Default); + Assert.False(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\?)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithQuestionMarkInPattern_Optional_WithDefaultValue_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(\?)=sdf?"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Equal("sdf", templatePart.Default); + Assert.True(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\?)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithQuestionMarkInName_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"par?am:test(\?)"); + + // Assert + Assert.Equal("par?am", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.False(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(\?)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithClosedParenAndColonInPattern_ParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(#):$)"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.False(templatePart.IsOptional); + + Assert.Collection(templatePart.Constraints, + constraint => Assert.Equal(@"test(#)", constraint.Content), + constraint => Assert.Equal(@"$)", constraint.Content)); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithColonAndClosedParenInPattern_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"param:test(#:)$)"); + + // Assert + Assert.Equal("param", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.False(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"test(#:)$)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ContainingMultipleUnclosedParenthesisInConstraint() + { + // Arrange & Act + var templatePart = ParseParameter(@"foo:regex(\\(\\(\\(\\()"); + + // Assert + Assert.Equal("foo", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.False(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"regex(\\(\\(\\(\\()", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithBraces_PatternIsParsedCorrectly() + { + // Arrange & Act + var templatePart = ParseParameter(@"p1:regex(^\d{{3}}-\d{{3}}-\d{{4}}$)"); // ssn + + // Assert + Assert.Equal("p1", templatePart.Name); + Assert.Null(templatePart.Default); + Assert.False(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"regex(^\d{{3}}-\d{{3}}-\d{{4}}$)", constraint.Content); + } + + [Fact] + public void ParseRouteParameter_ConstraintWithBraces_WithDefaultValue() + { + // Arrange & Act + var templatePart = ParseParameter(@"p1:regex(^\d{{3}}-\d{{3}}-\d{{4}}$)=123-456-7890"); // ssn + + // Assert + Assert.Equal("p1", templatePart.Name); + Assert.Equal("123-456-7890", templatePart.Default); + Assert.False(templatePart.IsOptional); + + var constraint = Assert.Single(templatePart.Constraints); + Assert.Equal(@"regex(^\d{{3}}-\d{{3}}-\d{{4}}$)", constraint.Content); + } + + [Theory] + [InlineData("", "")] + [InlineData("?", "")] + [InlineData("*", "")] + [InlineData(" ", " ")] + [InlineData("\t", "\t")] + [InlineData("#!@#$%Q@#@%", "#!@#$%Q@#@%")] + [InlineData(",,,", ",,,")] + public void ParseRouteParameter_ParameterWithoutInlineConstraint_ReturnsTemplatePartWithEmptyInlineValues( + string parameter, + string expectedParameterName) + { + // Arrange & Act + var templatePart = ParseParameter(parameter); + + // Assert + Assert.Equal(expectedParameterName, templatePart.Name); + Assert.Empty(templatePart.Constraints); + Assert.Null(templatePart.Default); + } + + private RoutePatternParameterPart ParseParameter(string routeParameter) + { + // See: #475 - these tests don't pass the 'whole' text. + var templatePart = RouteParameterParser.ParseRouteParameter(routeParameter); + return templatePart; + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternFactoryTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternFactoryTest.cs new file mode 100644 index 0000000000..34db4f7ffa --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternFactoryTest.cs @@ -0,0 +1,247 @@ +// 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.Linq; +using Microsoft.AspNetCore.Routing.Constraints; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + public class RoutePatternFactoryTest + { + [Fact] + public void Pattern_MergesDefaultValues() + { + // Arrange + var template = "{a}/{b}/{c=19}"; + var defaults = new { a = "15", b = 17 }; + var constraints = new { }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var actual = RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments); + + // Assert + Assert.Equal("15", actual.GetParameter("a").Default); + Assert.Equal(17, actual.GetParameter("b").Default); + Assert.Equal("19", actual.GetParameter("c").Default); + + Assert.Collection( + actual.Defaults.OrderBy(kvp => kvp.Key), + kvp => { Assert.Equal("a", kvp.Key); Assert.Equal("15", kvp.Value); }, + kvp => { Assert.Equal("b", kvp.Key); Assert.Equal(17, kvp.Value); }, + kvp => { Assert.Equal("c", kvp.Key); Assert.Equal("19", kvp.Value); }); + } + + [Fact] + public void Pattern_ExtraDefaultValues() + { + // Arrange + var template = "{a}/{b}/{c}"; + var defaults = new { d = "15", e = 17 }; + var constraints = new { }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var actual = RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments); + + // Assert + Assert.Collection( + actual.Defaults.OrderBy(kvp => kvp.Key), + kvp => { Assert.Equal("d", kvp.Key); Assert.Equal("15", kvp.Value); }, + kvp => { Assert.Equal("e", kvp.Key); Assert.Equal(17, kvp.Value); }); + } + + [Fact] + public void Pattern_DuplicateDefaultValue_Throws() + { + // Arrange + var template = "{a=13}/{b}/{c}"; + var defaults = new { a = "15", }; + var constraints = new { }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var ex = Assert.Throws(() => RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments)); + + // Assert + Assert.Equal( + "The route parameter 'a' has both an inline default value and an explicit default " + + "value specified. A route parameter cannot contain an inline default value when a " + + "default value is specified explicitly. Consider removing one of them.", + ex.Message); + } + + [Fact] + public void Pattern_OptionalParameterDefaultValue_Throws() + { + // Arrange + var template = "{a}/{b}/{c?}"; + var defaults = new { c = "15", }; + var constraints = new { }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var ex = Assert.Throws(() => RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments)); + + // Assert + Assert.Equal( + "An optional parameter cannot have default value.", + ex.Message); + } + + [Fact] + public void Pattern_MergesConstraints() + { + // Arrange + var template = "{a:int}/{b}/{c}"; + var defaults = new { }; + var constraints = new { a = new RegexRouteConstraint("foo"), b = new RegexRouteConstraint("bar") }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var actual = RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments); + + // Assert + Assert.Collection( + actual.GetParameter("a").Constraints, + c => Assert.IsType(c.Constraint), + c => Assert.Equal("int", c.Content)); + Assert.Collection( + actual.GetParameter("b").Constraints, + c => Assert.IsType(c.Constraint)); + + Assert.Collection( + actual.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("a", kvp.Key); + Assert.Collection( + kvp.Value, + c => Assert.IsType(c.Constraint), + c => Assert.Equal("int", c.Content)); + }, + kvp => + { + Assert.Equal("b", kvp.Key); + Assert.Collection( + kvp.Value, + c => Assert.IsType(c.Constraint)); + }); + } + + [Fact] + public void Pattern_ExtraConstraints() + { + // Arrange + var template = "{a}/{b}/{c}"; + var defaults = new { }; + var constraints = new { d = new RegexRouteConstraint("foo"), e = new RegexRouteConstraint("bar") }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var actual = RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments); + + // Assert + Assert.Collection( + actual.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("d", kvp.Key); + Assert.Collection( + kvp.Value, + c => Assert.IsType(c.Constraint)); + }, + kvp => + { + Assert.Equal("e", kvp.Key); + Assert.Collection( + kvp.Value, + c => Assert.IsType(c.Constraint)); + }); + } + + [Fact] + public void Pattern_CreatesConstraintFromString() + { + // Arrange + var template = "{a}/{b}/{c}"; + var defaults = new { }; + var constraints = new { d = "foo", }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var actual = RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments); + + // Assert + Assert.Collection( + actual.Constraints.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("d", kvp.Key); + var regex = Assert.IsType(Assert.Single(kvp.Value).Constraint); + Assert.Equal("^(foo)$", regex.Constraint.ToString()); + }); + } + + [Fact] + public void Pattern_InvalidConstraintTypeThrows() + { + // Arrange + var template = "{a}/{b}/{c}"; + var defaults = new { }; + var constraints = new { d = 17, }; + + var original = RoutePatternFactory.Parse(template); + + // Act + var ex = Assert.Throws(() => RoutePatternFactory.Pattern( + original.RawText, + defaults, + constraints, + original.PathSegments)); + + // Assert + Assert.Equal( + "The constraint entry 'd' - '17' must have a string value or be of a type " + + "which implements 'Microsoft.AspNetCore.Routing.IRouteConstraint'.", + ex.Message); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternMatcherTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternMatcherTest.cs new file mode 100644 index 0000000000..b9a3f7f997 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternMatcherTest.cs @@ -0,0 +1,1131 @@ +// 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.Routing.Patterns; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class RoutePatternMatcherTest + { + [Fact] + public void TryMatch_Success() + { + // Arrange + var matcher = CreateMatcher("{controller}/{action}/{id}"); + + var values = new RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(new { lang = "en", region = "US" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnLeftEndMatches() + { + RunTest( + "language/{lang}-{region}a", + "/language/en-USa", + null, + new RouteValueDictionary(new { lang = "en", region = "US" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnRightEndMatches() + { + RunTest( + "language/a{lang}-{region}", + "/language/aen-US", + null, + new RouteValueDictionary(new { lang = "en", region = "US" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnNeitherEndMatches() + { + RunTest( + "language/a{lang}-{region}a", + "/language/aen-USa", + null, + new RouteValueDictionary(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 RouteValueDictionary(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 RouteValueDictionary(new { lang = "en" })); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnRightEndMatches() + { + RunTest( + "language/a{lang}", + "/language/aen", + null, + new RouteValueDictionary(new { lang = "en" })); + } + + [Fact] + public void TryMatch_WithSimpleMultiSegmentParamsOnNeitherEndMatches() + { + RunTest( + "language/a{lang}a", + "/language/aena", + null, + new RouteValueDictionary(new { lang = "en" })); + } + + [Fact] + public void TryMatch_WithMultiSegmentStandamatchMvcRouteMatches() + { + RunTest( + "{controller}.mvc/{action}/{id}", + "/home.mvc/index", + new RouteValueDictionary(new { action = "Index", id = (string)null }), + new RouteValueDictionary(new { controller = "home", action = "index", id = (string)null })); + } + + [Fact] + public void TryMatch_WithMultiSegmentParamsOnBothEndsWithDefaultValuesMatches() + { + RunTest( + "language/{lang}-{region}", + "/language/-", + new RouteValueDictionary(new { lang = "xx", region = "yy" }), + null); + } + + [Fact] + public void TryMatch_WithUrlWithMultiSegmentWithRepeatedDots() + { + RunTest( + "{Controller}..mvc/{id}/{Param1}", + "/Home..mvc/123/p1", + null, + new RouteValueDictionary(new { Controller = "Home", id = "123", Param1 = "p1" })); + } + + [Fact] + public void TryMatch_WithUrlWithTwoRepeatedDots() + { + RunTest( + "{Controller}.mvc/../{action}", + "/Home.mvc/../index", + null, + new RouteValueDictionary(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithThreeRepeatedDots() + { + RunTest( + "{Controller}.mvc/.../{action}", + "/Home.mvc/.../index", + null, + new RouteValueDictionary(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithManyRepeatedDots() + { + RunTest( + "{Controller}.mvc/../../../{action}", + "/Home.mvc/../../../index", + null, + new RouteValueDictionary(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithExclamationPoint() + { + RunTest( + "{Controller}.mvc!/{action}", + "/Home.mvc!/index", + null, + new RouteValueDictionary(new { Controller = "Home", action = "index" })); + } + + [Fact] + public void TryMatch_WithUrlWithStartingDotDotSlash() + { + RunTest( + "../{Controller}.mvc", + "/../Home.mvc", + null, + new RouteValueDictionary(new { Controller = "Home" })); + } + + [Fact] + public void TryMatch_WithUrlWithStartingBackslash() + { + RunTest( + @"\{Controller}.mvc", + @"/\Home.mvc", + null, + new RouteValueDictionary(new { Controller = "Home" })); + } + + [Fact] + public void TryMatch_WithUrlWithBackslashSeparators() + { + RunTest( + @"{Controller}.mvc\{id}\{Param1}", + @"/Home.mvc\123\p1", + null, + new RouteValueDictionary(new { Controller = "Home", id = "123", Param1 = "p1" })); + } + + [Fact] + public void TryMatch_WithUrlWithParenthesesLiterals() + { + RunTest( + @"(Controller).mvc", + @"/(Controller).mvc", + null, + new RouteValueDictionary()); + } + + [Fact] + public void TryMatch_WithUrlWithTrailingSlashSpace() + { + RunTest( + @"Controller.mvc/ ", + @"/Controller.mvc/ ", + null, + new RouteValueDictionary()); + } + + [Fact] + public void TryMatch_WithUrlWithTrailingSpace() + { + RunTest( + @"Controller.mvc ", + @"/Controller.mvc ", + null, + new RouteValueDictionary()); + } + + [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 RouteValueDictionary(new + { + controller = "Home", + action = "ShowPilot", + missionId = (string)null, + name = (string)null + }), + new RouteValueDictionary(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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(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 RouteValueDictionary(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 RouteValueDictionary(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 RouteValueDictionary()); + } + + [Fact] + public void TryMatch_WithWeimatchParameterNames() + { + RunTest( + "foo/{ }/{.!$%}/{dynamic.data}/{op.tional}", + "/foo/space/weimatch/omatcherid", + new RouteValueDictionary() { { " ", "not a space" }, { "op.tional", "default value" }, { "ran!dom", "va@lue" } }, + new RouteValueDictionary() { { " ", "space" }, { ".!$%", "weimatch" }, { "dynamic.data", "omatcherid" }, { "op.tional", "default value" }, { "ran!dom", "va@lue" } }); + } + + [Fact] + public void TryMatch_DoesNotMatchRouteWithLiteralSeparatomatchefaultsButNoValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo", + new RouteValueDictionary(new { language = "en", locale = "US" }), + null); + } + + [Fact] + public void TryMatch_DoesNotMatchesRouteWithLiteralSeparatomatchefaultsAndLeftValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo/xx-", + new RouteValueDictionary(new { language = "en", locale = "US" }), + null); + } + + [Fact] + public void TryMatch_DoesNotMatchesRouteWithLiteralSeparatomatchefaultsAndRightValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo/-yy", + new RouteValueDictionary(new { language = "en", locale = "US" }), + null); + } + + [Fact] + public void TryMatch_MatchesRouteWithLiteralSeparatomatchefaultsAndValue() + { + RunTest( + "{controller}/{language}-{locale}", + "/foo/xx-yy", + new RouteValueDictionary(new { language = "en", locale = "US" }), + new RouteValueDictionary { { "language", "xx" }, { "locale", "yy" }, { "controller", "foo" } }); + } + + [Fact] + public void TryMatch_SetsOptionalParameter() + { + // Arrange + var route = CreateMatcher("{controller}/{action?}"); + var url = "/Home/Index"; + + var values = new RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(); + + // 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 RouteValueDictionary(defaults)); + } + + private static void RunTest( + string template, + string path, + RouteValueDictionary defaults, + IDictionary expected) + { + // Arrange + var matcher = new RoutePatternMatcher( + RoutePatternParser.Parse(template), + defaults ?? new RouteValueDictionary()); + + var values = new RouteValueDictionary(); + + // 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]); + } + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternParserTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternParserTest.cs new file mode 100644 index 0000000000..a4b4e6db63 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Patterns/RoutePatternParserTest.cs @@ -0,0 +1,765 @@ +// 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 System.Linq; +using Microsoft.AspNetCore.Testing; +using Xunit; +using static Microsoft.AspNetCore.Routing.Patterns.RoutePatternFactory; + +namespace Microsoft.AspNetCore.Routing.Patterns +{ + public class RoutePatternParameterParserTest + { + [Fact] + public void Parse_SingleLiteral() + { + // Arrange + var template = "cool"; + + var expected = Pattern( + template, + Segment(LiteralPart("cool"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_SingleParameter() + { + // Arrange + var template = "{p}"; + + var expected = Pattern(template, Segment(ParameterPart("p"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_OptionalParameter() + { + // Arrange + var template = "{p?}"; + + var expected = Pattern(template, Segment(ParameterPart("p", null, RoutePatternParameterKind.Optional))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_MultipleLiterals() + { + // Arrange + var template = "cool/awesome/super"; + + var expected = Pattern( + template, + Segment(LiteralPart("cool")), + Segment(LiteralPart("awesome")), + Segment(LiteralPart("super"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_MultipleParameters() + { + // Arrange + var template = "{p1}/{p2}/{*p3}"; + + var expected = Pattern( + template, + Segment(ParameterPart("p1")), + Segment(ParameterPart("p2")), + Segment(ParameterPart("p3", null, RoutePatternParameterKind.CatchAll))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_LP() + { + // Arrange + var template = "cool-{p1}"; + + var expected = Pattern( + template, + Segment( + LiteralPart("cool-"), + ParameterPart("p1"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_PL() + { + // Arrange + var template = "{p1}-cool"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1"), + LiteralPart("-cool"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_PLP() + { + // Arrange + var template = "{p1}-cool-{p2}"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1"), + LiteralPart("-cool-"), + ParameterPart("p2"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_LPL() + { + // Arrange + var template = "cool-{p1}-awesome"; + + var expected = Pattern( + template, + Segment( + LiteralPart("cool-"), + ParameterPart("p1"), + LiteralPart("-awesome"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_OptionalParameterFollowingPeriod() + { + // Arrange + var template = "{p1}.{p2?}"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1"), + SeparatorPart("."), + ParameterPart("p2", null, RoutePatternParameterKind.Optional))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_ParametersFollowingPeriod() + { + // Arrange + var template = "{p1}.{p2}"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1"), + LiteralPart("."), + ParameterPart("p2"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_OptionalParameterFollowingPeriod_ThreeParameters() + { + // Arrange + var template = "{p1}.{p2}.{p3?}"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1"), + LiteralPart("."), + ParameterPart("p2"), + SeparatorPart("."), + ParameterPart("p3", null, RoutePatternParameterKind.Optional))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_ThreeParametersSeperatedByPeriod() + { + // Arrange + var template = "{p1}.{p2}.{p3}"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1"), + LiteralPart("."), + ParameterPart("p2"), + LiteralPart("."), + ParameterPart("p3"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_OptionalParameterFollowingPeriod_MiddleSegment() + { + // Arrange + var template = "{p1}.{p2?}/{p3}"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1"), + SeparatorPart("."), + ParameterPart("p2", null, RoutePatternParameterKind.Optional)), + Segment( + ParameterPart("p3"))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_OptionalParameterFollowingPeriod_LastSegment() + { + // Arrange + var template = "{p1}/{p2}.{p3?}"; + + var expected = Pattern( + template, + Segment( + ParameterPart("p1")), + Segment( + ParameterPart("p2"), + SeparatorPart("."), + ParameterPart("p3", null, RoutePatternParameterKind.Optional))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Fact] + public void Parse_ComplexSegment_OptionalParameterFollowingPeriod_PeriodAfterSlash() + { + // Arrange + var template = "{p2}/.{p3?}"; + + var expected = Pattern( + template, + Segment(ParameterPart("p2")), + Segment( + SeparatorPart("."), + ParameterPart("p3", null, RoutePatternParameterKind.Optional))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Theory] + [InlineData(@"{p1:regex(^\d{{3}}-\d{{3}}-\d{{4}}$)}", @"regex(^\d{3}-\d{3}-\d{4}$)")] // ssn + [InlineData(@"{p1:regex(^\d{{1,2}}\/\d{{1,2}}\/\d{{4}}$)}", @"regex(^\d{1,2}\/\d{1,2}\/\d{4}$)")] // date + [InlineData(@"{p1:regex(^\w+\@\w+\.\w+)}", @"regex(^\w+\@\w+\.\w+)")] // email + [InlineData(@"{p1:regex(([}}])\w+)}", @"regex(([}])\w+)")] // Not balanced } + [InlineData(@"{p1:regex(([{{(])\w+)}", @"regex(([{(])\w+)")] // Not balanced { + public void Parse_RegularExpressions(string template, string constraint) + { + // Arrange + var expected = Pattern( + template, + Segment( + ParameterPart( + "p1", + null, + RoutePatternParameterKind.Standard, + Constraint("p1", constraint)))); + + // Act + var actual = RoutePatternParser.Parse(template); + + // Assert + Assert.Equal(expected, actual, new RoutePatternEqualityComparer()); + } + + [Theory] + [InlineData(@"{p1:regex(^\d{{3}}-\d{{3}}-\d{{4}}}$)}")] // extra } + [InlineData(@"{p1:regex(^\d{{3}}-\d{{3}}-\d{{4}}$)}}")] // extra } at the end + [InlineData(@"{{p1:regex(^\d{{3}}-\d{{3}}-\d{{4}}$)}")] // extra { at the begining + [InlineData(@"{p1:regex(([}])\w+}")] // Not escaped } + [InlineData(@"{p1:regex(^\d{{3}}-\d{{3}}-\d{{4}$)}")] // Not escaped } + [InlineData(@"{p1:regex(abc)")] + public void Parse_RegularExpressions_Invalid(string template) + { + // Act and Assert + ExceptionAssert.Throws( + () => RoutePatternParser.Parse(template), + "There is an incomplete parameter in the route template. Check that each '{' character has a matching " + + "'}' character."); + } + + [Theory] + [InlineData(@"{p1:regex(^\d{{3}}-\d{{3}}-\d{{{4}}$)}")] // extra { + [InlineData(@"{p1:regex(^\d{{3}}-\d{{3}}-\d{4}}$)}")] // Not escaped { + public void Parse_RegularExpressions_Unescaped(string template) + { + // Act and Assert + ExceptionAssert.Throws( + () => RoutePatternParser.Parse(template), + "In a route parameter, '{' and '}' must be escaped with '{{' and '}}'."); + } + + [Theory] + [InlineData("{p1}.{p2?}.{p3}", "p2", ".")] + [InlineData("{p1?}{p2}", "p1", "{p2}")] + [InlineData("{p1?}{p2?}", "p1", "{p2?}")] + [InlineData("{p1}.{p2?})", "p2", ")")] + [InlineData("{foorb?}-bar-{z}", "foorb", "-bar-")] + public void Parse_ComplexSegment_OptionalParameter_NotTheLastPart( + string template, + string parameter, + string invalid) + { + // Act and Assert + ExceptionAssert.Throws( + () => RoutePatternParser.Parse(template), + "An optional parameter must be at the end of the segment. In the segment '" + template + + "', optional parameter '" + parameter + "' is followed by '" + invalid + "'."); + } + + [Theory] + [InlineData("{p1}-{p2?}", "-")] + [InlineData("{p1}..{p2?}", "..")] + [InlineData("..{p2?}", "..")] + [InlineData("{p1}.abc.{p2?}", ".abc.")] + [InlineData("{p1}{p2?}", "{p1}")] + public void Parse_ComplexSegment_OptionalParametersSeperatedByPeriod_Invalid(string template, string parameter) + { + // Act and Assert + ExceptionAssert.Throws( + () => RoutePatternParser.Parse(template), + "In the segment '" + template + "', the optional parameter 'p2' is preceded by an invalid " + + "segment '" + parameter + "'. Only a period (.) can precede an optional parameter."); + } + + [Fact] + public void InvalidTemplate_WithRepeatedParameter() + { + var ex = ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{Controller}.mvc/{id}/{controller}"), + "The route parameter name 'controller' appears more than one time in the route template."); + } + + [Theory] + [InlineData("123{a}abc{")] + [InlineData("123{a}abc}")] + [InlineData("xyz}123{a}abc}")] + [InlineData("{{p1}")] + [InlineData("{p1}}")] + [InlineData("p1}}p2{")] + public void InvalidTemplate_WithMismatchedBraces(string template) + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse(template), + @"There is an incomplete parameter in the route template. Check that each '{' character has a " + + "matching '}' character."); + } + + [Fact] + public void InvalidTemplate_CannotHaveCatchAllInMultiSegment() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("123{a}abc{*moo}"), + "A path segment that contains more than one section, such as a literal section or a parameter, " + + "cannot contain a catch-all parameter."); + } + + [Fact] + public void InvalidTemplate_CannotHaveMoreThanOneCatchAll() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{*p1}/{*p2}"), + "A catch-all parameter can only appear as the last segment of the route template."); + } + + [Fact] + public void InvalidTemplate_CannotHaveMoreThanOneCatchAllInMultiSegment() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{*p1}abc{*p2}"), + "A path segment that contains more than one section, such as a literal section or a parameter, " + + "cannot contain a catch-all parameter."); + } + + [Fact] + public void InvalidTemplate_CannotHaveCatchAllWithNoName() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("foo/{*}"), + "The route parameter name '' is invalid. Route parameter names must be non-empty and cannot" + + " contain these characters: '{', '}', '/'. The '?' character marks a parameter as optional," + + " and can occur only at the end of the parameter. The '*' character marks a parameter as catch-all," + + " and can occur only at the start of the parameter."); + } + + [Theory] + [InlineData("{**}", "*")] + [InlineData("{a*}", "a*")] + [InlineData("{*a*}", "a*")] + [InlineData("{*a*:int}", "a*")] + [InlineData("{*a*=5}", "a*")] + [InlineData("{*a*b=5}", "a*b")] + [InlineData("{p1?}.{p2/}/{p3}", "p2/")] + [InlineData("{p{{}", "p{")] + [InlineData("{p}}}", "p}")] + [InlineData("{p/}", "p/")] + public void ParseRouteParameter_ThrowsIf_ParameterContainsSpecialCharacters( + string template, + string parameterName) + { + // Arrange + var expectedMessage = "The route parameter name '" + parameterName + "' is invalid. Route parameter " + + "names must be non-empty and cannot contain these characters: '{', '}', '/'. The '?' character " + + "marks a parameter as optional, and can occur only at the end of the parameter. The '*' character " + + "marks a parameter as catch-all, and can occur only at the start of the parameter."; + + // Act & Assert + ExceptionAssert.Throws(() => RoutePatternParser.Parse(template), expectedMessage); + } + + [Fact] + public void InvalidTemplate_CannotHaveConsecutiveOpenBrace() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("foo/{{p1}"), + "There is an incomplete parameter in the route template. Check that each '{' character has a " + + "matching '}' character."); + } + + [Fact] + public void InvalidTemplate_CannotHaveConsecutiveCloseBrace() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("foo/{p1}}"), + "There is an incomplete parameter in the route template. Check that each '{' character has a " + + "matching '}' character."); + } + + [Fact] + public void InvalidTemplate_SameParameterTwiceThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{aaa}/{AAA}"), + "The route parameter name 'AAA' appears more than one time in the route template."); + } + + [Fact] + public void InvalidTemplate_SameParameterTwiceAndOneCatchAllThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{aaa}/{*AAA}"), + "The route parameter name 'AAA' appears more than one time in the route template."); + } + + [Fact] + public void InvalidTemplate_InvalidParameterNameWithCloseBracketThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{a}/{aa}a}/{z}"), + "There is an incomplete parameter in the route template. Check that each '{' character has a " + + "matching '}' character."); + } + + [Fact] + public void InvalidTemplate_InvalidParameterNameWithOpenBracketThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{a}/{a{aa}/{z}"), + "In a route parameter, '{' and '}' must be escaped with '{{' and '}}'."); + } + + [Fact] + public void InvalidTemplate_InvalidParameterNameWithEmptyNameThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{a}/{}/{z}"), + "The route parameter name '' is invalid. Route parameter names must be non-empty and cannot" + + " contain these characters: '{', '}', '/'. The '?' character marks a parameter as optional, and" + + " can occur only at the end of the parameter. The '*' character marks a parameter as catch-all," + + " and can occur only at the start of the parameter."); + } + + [Fact] + public void InvalidTemplate_InvalidParameterNameWithQuestionThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{Controller}.mvc/{?}"), + "The route parameter name '' is invalid. Route parameter names must be non-empty and cannot" + + " contain these characters: '{', '}', '/'. The '?' character marks a parameter as optional, and" + + " can occur only at the end of the parameter. The '*' character marks a parameter as catch-all," + + " and can occur only at the start of the parameter."); + } + + [Fact] + public void InvalidTemplate_ConsecutiveSeparatorsSlashSlashThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{a}//{z}"), + "The route template separator character '/' cannot appear consecutively. It must be separated by " + + "either a parameter or a literal value."); + } + + [Fact] + public void InvalidTemplate_WithCatchAllNotAtTheEndThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("foo/{p1}/{*p2}/{p3}"), + "A catch-all parameter can only appear as the last segment of the route template."); + } + + [Fact] + public void InvalidTemplate_RepeatedParametersThrows() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("foo/aa{p1}{p2}"), + "A path segment cannot contain two consecutive parameters. They must be separated by a '/' or by " + + "a literal string."); + } + + [Theory] + [InlineData("/foo")] + [InlineData("~/foo")] + public void ValidTemplate_CanStartWithSlashOrTildeSlash(string routePattern) + { + // Arrange & Act + var pattern = RoutePatternParser.Parse(routePattern); + + // Assert + Assert.Equal(routePattern, pattern.RawText); + } + + [Fact] + public void InvalidTemplate_CannotStartWithTilde() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("~foo"), + "The route template cannot start with a '~' character unless followed by a '/'."); + } + + [Fact] + public void InvalidTemplate_CannotContainQuestionMark() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("foor?bar"), + "The literal section 'foor?bar' is invalid. Literal sections cannot contain the '?' character."); + } + + [Fact] + public void InvalidTemplate_ParameterCannotContainQuestionMark_UnlessAtEnd() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{foor?b}"), + "The route parameter name 'foor?b' is invalid. Route parameter names must be non-empty and cannot" + + " contain these characters: '{', '}', '/'. The '?' character marks a parameter as optional, and" + + " can occur only at the end of the parameter. The '*' character marks a parameter as catch-all," + + " and can occur only at the start of the parameter."); + } + + [Fact] + public void InvalidTemplate_CatchAllMarkedOptional() + { + ExceptionAssert.Throws( + () => RoutePatternParser.Parse("{a}/{*b?}"), + "A catch-all parameter cannot be marked optional."); + } + + private class RoutePatternEqualityComparer : + IEqualityComparer, + IEqualityComparer + { + public bool Equals(RoutePattern x, RoutePattern y) + { + if (x == null && y == null) + { + return true; + } + else if (x == null || y == null) + { + return false; + } + else + { + if (!string.Equals(x.RawText, y.RawText, StringComparison.Ordinal)) + { + return false; + } + + if (x.PathSegments.Count != y.PathSegments.Count) + { + return false; + } + + for (int i = 0; i < x.PathSegments.Count; i++) + { + if (x.PathSegments[i].Parts.Count != y.PathSegments[i].Parts.Count) + { + return false; + } + + for (int j = 0; j < x.PathSegments[i].Parts.Count; j++) + { + if (!Equals(x.PathSegments[i].Parts[j], y.PathSegments[i].Parts[j])) + { + return false; + } + } + } + + if (x.Parameters.Count != y.Parameters.Count) + { + return false; + } + + for (int i = 0; i < x.Parameters.Count; i++) + { + if (!Equals(x.Parameters[i], y.Parameters[i])) + { + return false; + } + } + + return true; + } + } + + private bool Equals(RoutePatternPart x, RoutePatternPart y) + { + if (x.GetType() != y.GetType()) + { + return false; + } + + if (x.IsLiteral && y.IsLiteral) + { + return Equals((RoutePatternLiteralPart)x, (RoutePatternLiteralPart)y); + } + else if (x.IsParameter && y.IsParameter) + { + return Equals((RoutePatternParameterPart)x, (RoutePatternParameterPart)y); + } + else if (x.IsSeparator && y.IsSeparator) + { + return Equals((RoutePatternSeparatorPart)x, (RoutePatternSeparatorPart)y); + } + + Debug.Fail("This should not be reachable. Do you need to update the comparison logic?"); + return false; + } + + private bool Equals(RoutePatternLiteralPart x, RoutePatternLiteralPart y) + { + return x.Content == y.Content; + } + + private bool Equals(RoutePatternParameterPart x, RoutePatternParameterPart y) + { + return + x.Name == y.Name && + x.Default == y.Default && + x.ParameterKind == y.ParameterKind && + Enumerable.SequenceEqual(x.Constraints, y.Constraints, this); + + } + + public bool Equals(RoutePatternConstraintReference x, RoutePatternConstraintReference y) + { + return + x.ParameterName == y.ParameterName && + x.Content == y.Content && + x.Constraint == y.Constraint; + } + + private bool Equals(RoutePatternSeparatorPart x, RoutePatternSeparatorPart y) + { + return x.Content == y.Content; + } + + public int GetHashCode(RoutePattern obj) + { + throw new NotImplementedException(); + } + + public int GetHashCode(RoutePatternConstraintReference obj) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateParserTests.cs b/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateParserTests.cs index 7f9f9b8b40..eabc3ec25e 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateParserTests.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Template/TemplateParserTests.cs @@ -5,9 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Testing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Moq; using Xunit; namespace Microsoft.AspNetCore.Routing.Template.Tests @@ -528,8 +525,8 @@ namespace Microsoft.AspNetCore.Routing.Template.Tests [Theory] [InlineData("{p1}.{p2?}.{p3}", "p2", ".")] - [InlineData("{p1?}{p2}", "p1", "p2")] - [InlineData("{p1?}{p2?}", "p1", "p2")] + [InlineData("{p1?}{p2}", "p1", "{p2}")] + [InlineData("{p1?}{p2?}", "p1", "{p2?}")] [InlineData("{p1}.{p2?})", "p2", ")")] [InlineData("{foorb?}-bar-{z}", "foorb", "-bar-")] public void Parse_ComplexSegment_OptionalParameter_NotTheLastPart( @@ -655,18 +652,6 @@ namespace Microsoft.AspNetCore.Routing.Template.Tests "Parameter name: routeTemplate"); } - [Theory] - [InlineData("/foo")] - [InlineData("~/foo")] - public void ValidTemplate_CanStartWithSlashOrTildeSlash(string routeTemplate) - { - // Arrange & Act - var template = TemplateParser.Parse(routeTemplate); - - // Assert - Assert.Equal(routeTemplate, template.TemplateText); - } - [Fact] public void InvalidTemplate_CannotHaveConsecutiveOpenBrace() { @@ -780,6 +765,18 @@ namespace Microsoft.AspNetCore.Routing.Template.Tests "Parameter name: routeTemplate"); } + [Theory] + [InlineData("/foo")] + [InlineData("~/foo")] + public void ValidTemplate_CanStartWithSlashOrTildeSlash(string routeTemplate) + { + // Arrange & Act + var pattern = TemplateParser.Parse(routeTemplate); + + // Assert + Assert.Equal(routeTemplate, pattern.TemplateText); + } + [Fact] public void InvalidTemplate_CannotStartWithTilde() {