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() {