using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using EmbedIO.WebApi;
namespace EmbedIO.Routing
{
///
/// Provides utility methods to work with routes.
///
///
///
///
public static class Route
{
// Characters in ValidParameterNameChars MUST be in ascending ordinal order!
private static readonly char[] ValidParameterNameChars =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".ToCharArray();
///
/// Determines whether a string is a valid route parameter name.
/// To be considered a valid route parameter name, the specified string:
///
/// - must not be ;
/// - must not be the empty string;
/// - must consist entirely of decimal digits, upper- or lower-case
/// letters of the English alphabet, or underscore ('_') characters;
/// - must not start with a decimal digit.
///
///
/// The value.
/// if is a valid route parameter;
/// otherwise, .
public static bool IsValidParameterName(string value)
=> !string.IsNullOrEmpty(value)
&& value[0] > '9'
&& !value.Any(c => c < '0' || c > 'z' || Array.BinarySearch(ValidParameterNameChars, c) < 0);
///
/// Determines whether a string is a valid route.
/// To be considered a valid route, the specified string:
///
/// - must not be ;
/// - must not be the empty string;
/// - must start with a slash ('/') character;
/// - if a base route, must end with a slash ('/') character;
/// - if not a base route, must not end with a slash ('/') character,
/// unless it is the only character in the string;
/// - must not contain consecutive runs of two or more slash ('/') characters;
/// - may contain one or more parameter specifications.
///
/// Each parameter specification must be enclosed in curly brackets ('{'
/// and '}'. No whitespace is allowed inside a parameter specification.
/// Two parameter specifications must be separated by literal text.
/// A parameter specification consists of a valid parameter name, optionally
/// followed by a '?' character to signify that it will also match an empty string.
/// If '?' is not present, a parameter by default will NOT match an empty string.
/// See for the definition of a valid parameter name.
/// To include a literal open curly bracket in the route, it must be doubled ("{{").
/// A literal closed curly bracket ('}') may be included in the route as-is.
/// A segment of a base route cannot consist only of an optional parameter.
///
/// The route to check.
/// if checking for a base route;
/// otherwise, .
/// if is a valid route;
/// otherwise, .
public static bool IsValid(string route, bool isBaseRoute) => ValidateInternal(nameof(route), route, isBaseRoute) == null;
// Check the validity of a route by parsing it without storing the results.
// Returns: ArgumentNullException, ArgumentException, null if OK
internal static Exception? ValidateInternal(string argumentName, string value, bool isBaseRoute) => ParseInternal(value, isBaseRoute, null) switch {
ArgumentNullException _ => new ArgumentNullException(argumentName),
FormatException formatException => new ArgumentException(formatException.Message, argumentName),
Exception exception => exception,
_ => null
};
// Validate and parse a route, constructing a Regex pattern.
// setResult will be called at the end with the isBaseRoute flag, parameter names and the constructed pattern.
// Returns: ArgumentNullException, FormatException, null if OK
internal static Exception? ParseInternal(string route, bool isBaseRoute, Action, string>? setResult)
{
if (route == null)
return new ArgumentNullException(nameof(route));
if (route.Length == 0)
return new FormatException("Route is empty.");
if (route[0] != '/')
return new FormatException("Route does not start with a slash.");
/*
* Regex options set at start of pattern:
* IgnoreCase : no
* Multiline : no
* Singleline : yes
* ExplicitCapture : yes
* IgnorePatternWhitespace : no
* See https://docs.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-options
* See https://docs.microsoft.com/en-us/dotnet/standard/base-types/grouping-constructs-in-regular-expressions#group_options
*/
const string InitialRegexOptions = "(?sn-imx)";
// If setResult is null we don't need the StringBuilder.
var sb = setResult == null ? null : new StringBuilder("^");
var parameterNames = new List();
if (route.Length == 1)
{
// If the route consists of a single slash, only a single slash will match.
sb?.Append(isBaseRoute ? "/" : "/$");
}
else
{
// First of all divide the route in segments.
// Segments are separated by slashes.
// The route is not necessarily normalized, so there could be runs of consecutive slashes.
var segmentCount = 0;
var optionalSegmentCount = 0;
foreach (var segment in GetSegments(route))
{
segmentCount++;
// Parse the segment, looking alternately for a '{', that opens a parameter specification,
// then for a '}', that closes it.
// Characters outside parameter specifications are Regex-escaped and added to the pattern.
// A parameter specification consists of a parameter name, optionally followed by '?'
// to indicate that an empty parameter will match.
// The default is to NOT match empty parameters, consistently with ASP.NET and EmbedIO version 2.
// More syntax rules:
// - There cannot be two parameters without literal text in between.
// - If a segment consists ONLY of an OPTIONAL parameter, then the slash preceding it is optional too.
var inParameterSpec = false;
var afterParameter = false;
for (var position = 0; ;)
{
if (inParameterSpec)
{
// Look for end of spec, bail out if not found.
var closePosition = segment.IndexOf('}', position);
if (closePosition < 0)
return new FormatException("Route syntax error: unclosed parameter specification.");
// Parameter spec cannot be empty.
if (closePosition == position)
return new FormatException("Route syntax error: empty parameter specification.");
// Check the last character:
// {name} means empty parameter does not match
// {name?} means empty parameter matches
// If '?'is found, the parameter name ends before it
var nameEndPosition = closePosition;
var allowEmpty = false;
if (segment[closePosition - 1] == '?')
{
allowEmpty = true;
nameEndPosition--;
}
// Bail out if only '?' is found inside the spec.
if (nameEndPosition == position)
return new FormatException("Route syntax error: missing parameter name.");
// Extract the parameter name.
var parameterName = segment.Substring(position, nameEndPosition - position);
// Ensure that the parameter name contains only valid characters.
if (!IsValidParameterName(parameterName))
return new FormatException("Route syntax error: parameter name contains one or more invalid characters.");
// Ensure that the parameter name is not a duplicate.
if (parameterNames.Contains(parameterName))
return new FormatException("Route syntax error: duplicate parameter name.");
// The spec is valid, so add the parameter to the list.
parameterNames.Add(parameterName);
// Append a capturing group with the same name to the pattern.
// Parameters must be made of non-slash characters ("[^/]")
// and must match non-greedily ("*?" if optional, "+?" if non optional).
// Position will be 1 at the start, not 0, because we've skipped the opening '{'.
if (allowEmpty && position == 1 && closePosition == segment.Length - 1)
{
if (isBaseRoute)
return new FormatException("No segment of a base route can be optional.");
// If the segment consists only of an optional parameter,
// then the slash preceding the segment is optional as well.
// In this case the parameter must match only is not empty,
// because it's (slash + parameter) that is optional.
sb?.Append("(/(?<").Append(parameterName).Append(">[^/]+?))?");
optionalSegmentCount++;
}
else
{
// If at the start of a segment, don't forget the slash!
// Position will be 1 at the start, not 0, because we've skipped the opening '{'.
if (position == 1)
sb?.Append('/');
sb?.Append("(?<").Append(parameterName).Append(">[^/]").Append(allowEmpty ? '*' : '+').Append("?)");
}
// Go on with parsing.
position = closePosition + 1;
inParameterSpec = false;
afterParameter = true;
}
else
{
// Look for start of parameter spec.
var openPosition = segment.IndexOf('{', position);
if (openPosition < 0)
{
// If at the start of a segment, don't forget the slash.
if (position == 0)
sb?.Append('/');
// No more parameter specs: escape the remainder of the string
// and add it to the pattern.
sb?.Append(Regex.Escape(segment.Substring(position)));
break;
}
var nextPosition = openPosition + 1;
if (nextPosition < segment.Length && segment[nextPosition] == '{')
{
// If another identical char follows, treat the two as a single literal char.
// If at the start of a segment, don't forget the slash!
if (position == 0)
sb?.Append('/');
sb?.Append(@"\\{");
}
else if (afterParameter && openPosition == position)
{
// If a parameter immediately follows another parameter,
// with no literal text in between, it's a syntax error.
return new FormatException("Route syntax error: parameters must be separated by literal text.");
}
else
{
// If at the start of a segment, don't forget the slash,
// but only if there actually is some literal text.
// Otherwise let the parameter spec parsing code deal with the slash,
// because we don't know whether this is an optional segment yet.
if (position == 0 && openPosition > 0)
sb?.Append('/');
// Escape the part of the pattern outside the parameter spec
// and add it to the pattern.
sb?.Append(Regex.Escape(segment.Substring(position, openPosition - position)));
inParameterSpec = true;
}
// Go on parsing.
position = nextPosition;
afterParameter = false;
}
}
}
// Close the pattern
sb?.Append(isBaseRoute ? "(/|$)" : "$");
// If all segments are optional segments, "/" must match too.
if (optionalSegmentCount == segmentCount)
sb?.Insert(0, "(/$)|(").Append(')');
}
// Pass the results to the callback if needed.
setResult?.Invoke(isBaseRoute, parameterNames, InitialRegexOptions + sb);
// Everything's fine, thus no exception.
return null;
}
// Enumerate the segments of a route, ignoring consecutive slashes.
private static IEnumerable GetSegments(string route)
{
var length = route.Length;
var position = 0;
for (; ; )
{
while (route[position] == '/')
{
position++;
if (position >= length)
break;
}
if (position >= length)
break;
var slashPosition = route.IndexOf('/', position);
if (slashPosition < 0)
{
yield return route.Substring(position);
break;
}
yield return route.Substring(position, slashPosition - position);
position = slashPosition;
}
}
}
}