// // PatternParser.cs // // Author: // Atsushi Enomoto // Marek Habersack // // Copyright (C) 2008-2010 Novell Inc. http://novell.com // // // Permission is hereby granted, free of charge, to any person obtaining // a copy of this software and associated documentation files (the // "Software"), to deal in the Software without restriction, including // without limitation the rights to use, copy, modify, merge, publish, // distribute, sublicense, and/or sell copies of the Software, and to // permit persons to whom the Software is furnished to do so, subject to // the following conditions: // // The above copyright notice and this permission notice shall be // included in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // using System; using System.Collections.Generic; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Web; using System.Web.Util; using System.Diagnostics; using System.Globalization; namespace System.Web.Routing { sealed class PatternParser { struct PatternSegment { public bool AllLiteral; public List Tokens; } static readonly char[] placeholderDelimiters = { '{', '}' }; PatternSegment[] segments; Dictionary parameterNames; PatternToken[] tokens; int segmentCount; bool haveSegmentWithCatchAll; public string Url { get; private set; } public PatternParser (string pattern) { this.Url = pattern; Parse (); } void Parse () { string url = Url; parameterNames = new Dictionary (StringComparer.OrdinalIgnoreCase); if (!String.IsNullOrEmpty (url)) { if (url [0] == '~' || url [0] == '/') throw new ArgumentException ("Url must not start with '~' or '/'"); if (url.IndexOf ('?') >= 0) throw new ArgumentException ("Url must not contain '?'"); } else { segments = new PatternSegment [0]; tokens = new PatternToken [0]; return; } string[] parts = url.Split ('/'); int partsCount = segmentCount = parts.Length; var allTokens = new List (); PatternToken tmpToken; segments = new PatternSegment [partsCount]; for (int i = 0; i < partsCount; i++) { if (haveSegmentWithCatchAll) throw new ArgumentException ("A catch-all parameter can only appear as the last segment of the route URL"); int catchAlls = 0; string part = parts [i]; int partLength = part.Length; var tokens = new List (); if (partLength == 0 && i < partsCount - 1) throw new ArgumentException ("Consecutive URL segment separators '/' are not allowed"); if (part.IndexOf ("{}") != -1) throw new ArgumentException ("Empty URL parameter name is not allowed"); if (i > 0) allTokens.Add (null); if (part.IndexOfAny (placeholderDelimiters) == -1) { // no placeholders here, short-circuit it tmpToken = new PatternToken (PatternTokenType.Literal, part); tokens.Add (tmpToken); allTokens.Add (tmpToken); segments [i].AllLiteral = true; segments [i].Tokens = tokens; continue; } string tmp; int from = 0, start; bool allLiteral = true; while (from < partLength) { start = part.IndexOf ('{', from); if (start >= partLength - 2) throw new ArgumentException ("Unterminated URL parameter. It must contain matching '}'"); if (start < 0) { if (part.IndexOf ('}', from) >= from) throw new ArgumentException ("Unmatched URL parameter closer '}'. A corresponding '{' must precede"); tmp = part.Substring (from); tmpToken = new PatternToken (PatternTokenType.Literal, tmp); tokens.Add (tmpToken); allTokens.Add (tmpToken); from += tmp.Length; break; } if (from == 0 && start > 0) { tmpToken = new PatternToken (PatternTokenType.Literal, part.Substring (0, start)); tokens.Add (tmpToken); allTokens.Add (tmpToken); } int end = part.IndexOf ('}', start + 1); int next = part.IndexOf ('{', start + 1); if (end < 0 || next >= 0 && next < end) throw new ArgumentException ("Unterminated URL parameter. It must contain matching '}'"); if (next == end + 1) throw new ArgumentException ("Two consecutive URL parameters are not allowed. Split into a different segment by '/', or a literal string."); if (next == -1) next = partLength; string token = part.Substring (start + 1, end - start - 1); PatternTokenType type; if (token [0] == '*') { catchAlls++; haveSegmentWithCatchAll = true; type = PatternTokenType.CatchAll; token = token.Substring (1); } else type = PatternTokenType.Standard; if (!parameterNames.ContainsKey (token)) parameterNames.Add (token, true); tmpToken = new PatternToken (type, token); tokens.Add (tmpToken); allTokens.Add (tmpToken); allLiteral = false; if (end < partLength - 1) { token = part.Substring (end + 1, next - end - 1); tmpToken = new PatternToken (PatternTokenType.Literal, token); tokens.Add (tmpToken); allTokens.Add (tmpToken); end += token.Length; } if (catchAlls > 1 || (catchAlls == 1 && tokens.Count > 1)) throw new ArgumentException ("A path segment that contains more than one section, such as a literal section or a parameter, cannot contain a catch-all parameter."); from = end + 1; } segments [i].AllLiteral = allLiteral; segments [i].Tokens = tokens; } if (allTokens.Count > 0) this.tokens = allTokens.ToArray (); allTokens = null; } RouteValueDictionary AddDefaults (RouteValueDictionary dict, RouteValueDictionary defaults) { if (defaults != null && defaults.Count > 0) { string key; foreach (var def in defaults) { key = def.Key; if (dict.ContainsKey (key)) continue; dict.Add (key, def.Value); } } return dict; } static bool ParametersAreEqual (object a, object b) { if (a is string && b is string) { return String.Equals (a as string, b as string, StringComparison.OrdinalIgnoreCase); } else { // Parameter may be a boxed value type, need to use .Equals() for comparison return object.Equals (a, b); } } static bool ParameterIsNonEmpty (object param) { if (param is string) return !string.IsNullOrEmpty (param as string); return param != null; } bool IsParameterRequired (string parameterName, RouteValueDictionary defaultValues, out object defaultValue) { foreach (var token in tokens) { if (token == null) continue; if (string.Equals (token.Name, parameterName, StringComparison.OrdinalIgnoreCase)) { if (token.Type == PatternTokenType.CatchAll) { defaultValue = null; return false; } } } if (defaultValues == null) throw new ArgumentNullException ("defaultValues is null?!"); return !defaultValues.TryGetValue (parameterName, out defaultValue); } static string EscapeReservedCharacters (Match m) { if (m == null) throw new ArgumentNullException("m"); return Uri.HexEscape (m.Value[0]); } static string UriEncode (string str) { if (string.IsNullOrEmpty (str)) return str; string escape = Uri.EscapeUriString (str); return Regex.Replace (escape, "([#?])", new MatchEvaluator (EscapeReservedCharacters)); } bool MatchSegment (int segIndex, int argsCount, string[] argSegs, List tokens, RouteValueDictionary ret) { string pathSegment = argSegs [segIndex]; int pathSegmentLength = pathSegment != null ? pathSegment.Length : -1; int startIndex = pathSegmentLength - 1; PatternTokenType tokenType; int tokensCount = tokens.Count; PatternToken token; string tokenName; for (int tokenIndex = tokensCount - 1; tokenIndex > -1; tokenIndex--) { token = tokens [tokenIndex]; if (startIndex < 0) return false; tokenType = token.Type; tokenName = token.Name; if (segIndex > segmentCount - 1 || tokenType == PatternTokenType.CatchAll) { var sb = new StringBuilder (); for (int j = segIndex; j < argsCount; j++) { if (j > segIndex) sb.Append ('/'); sb.Append (argSegs [j]); } ret.Add (tokenName, sb.ToString ()); break; } int scanIndex; if (token.Type == PatternTokenType.Literal) { int nameLen = tokenName.Length; if (startIndex + 1 < nameLen) return false; scanIndex = startIndex - nameLen + 1; if (String.Compare (pathSegment, scanIndex, tokenName, 0, nameLen, StringComparison.OrdinalIgnoreCase) != 0) return false; startIndex = scanIndex - 1; continue; } // Standard token int nextTokenIndex = tokenIndex - 1; if (nextTokenIndex < 0) { // First token ret.Add (tokenName, pathSegment.Substring (0, startIndex + 1)); continue; } if (startIndex == 0) return false; var nextToken = tokens [nextTokenIndex]; string nextTokenName = nextToken.Name; // Skip one char, since there can be no empty segments and if the // current token's value happens to be the same as preceeding // literal text, we'll save some time and complexity. scanIndex = startIndex - 1; int lastIndex = pathSegment.LastIndexOf (nextTokenName, scanIndex, StringComparison.OrdinalIgnoreCase); if (lastIndex == -1) return false; lastIndex += nextTokenName.Length - 1; string sectionValue = pathSegment.Substring (lastIndex + 1, startIndex - lastIndex); if (String.IsNullOrEmpty (sectionValue)) return false; ret.Add (tokenName, sectionValue); startIndex = lastIndex; } return true; } public RouteValueDictionary Match (string path, RouteValueDictionary defaults) { var ret = new RouteValueDictionary (); string url = Url; string [] argSegs; int argsCount; if (String.IsNullOrEmpty (path)) { argSegs = null; argsCount = 0; } else { // quick check if (String.Compare (url, path, StringComparison.Ordinal) == 0 && url.IndexOf ('{') < 0) return AddDefaults (ret, defaults); argSegs = path.Split ('/'); argsCount = argSegs.Length; if (String.IsNullOrEmpty (argSegs [argsCount - 1])) argsCount--; // path ends with a trailinig '/' } bool haveDefaults = defaults != null && defaults.Count > 0; if (argsCount == 1 && String.IsNullOrEmpty (argSegs [0])) argsCount = 0; if (!haveDefaults && ((haveSegmentWithCatchAll && argsCount < segmentCount) || (!haveSegmentWithCatchAll && argsCount != segmentCount))) return null; int i = 0; foreach (PatternSegment segment in segments) { if (i >= argsCount) break; if (segment.AllLiteral) { if (String.Compare (argSegs [i], segment.Tokens [0].Name, StringComparison.OrdinalIgnoreCase) != 0) return null; i++; continue; } if (!MatchSegment (i, argsCount, argSegs, segment.Tokens, ret)) return null; i++; } // Check the remaining segments, if any, and see if they are required // // If a segment has more than one section (i.e. there's at least one // literal, then it cannot match defaults // // All of the remaining segments must have all defaults provided and they // must not be literals or the match will fail. if (i < segmentCount) { if (!haveDefaults) return null; for (;i < segmentCount; i++) { var segment = segments [i]; if (segment.AllLiteral) return null; var tokens = segment.Tokens; if (tokens.Count != 1) return null; // if token is catch-all, we're done. if (tokens [0].Type == PatternTokenType.CatchAll) break; if (!defaults.ContainsKey (tokens [0].Name)) return null; } } else if (!haveSegmentWithCatchAll && argsCount > segmentCount) return null; return AddDefaults (ret, defaults); } public string BuildUrl (Route route, RequestContext requestContext, RouteValueDictionary userValues, RouteValueDictionary constraints, out RouteValueDictionary usedValues) { usedValues = null; if (requestContext == null) return null; RouteData routeData = requestContext.RouteData; var currentValues = routeData.Values ?? new RouteValueDictionary (); var values = userValues ?? new RouteValueDictionary (); var defaultValues = (route != null ? route.Defaults : null) ?? new RouteValueDictionary (); // The set of values we should be using when generating the URL in this route var acceptedValues = new RouteValueDictionary (); // Keep track of which new values have been used HashSet unusedNewValues = new HashSet (values.Keys, StringComparer.OrdinalIgnoreCase); // This route building logic is based on System.Web.Http's Routing code (which is Apache Licensed by MS) // and which can be found at mono's external/aspnetwebstack/src/System.Web.Http/Routing/HttpParsedRoute.cs // Hopefully this will ensure a much higher compatiblity with MS.NET's System.Web.Routing logic. (pruiz) #region Step 1: Get the list of values we're going to use to match and generate this URL // Find out which entries in the URL are valid for the URL we want to generate. // If the URL had ordered parameters a="1", b="2", c="3" and the new values // specified that b="9", then we need to invalidate everything after it. The new // values should then be a="1", b="9", c=. foreach (var item in parameterNames) { var parameterName = item.Key; object newParameterValue; bool hasNewParameterValue = values.TryGetValue (parameterName, out newParameterValue); if (hasNewParameterValue) { unusedNewValues.Remove(parameterName); } object currentParameterValue; bool hasCurrentParameterValue = currentValues.TryGetValue (parameterName, out currentParameterValue); if (hasNewParameterValue && hasCurrentParameterValue) { if (!ParametersAreEqual (currentParameterValue, newParameterValue)) { // Stop copying current values when we find one that doesn't match break; } } // If the parameter is a match, add it to the list of values we will use for URL generation if (hasNewParameterValue) { if (ParameterIsNonEmpty (newParameterValue)) { acceptedValues.Add (parameterName, newParameterValue); } } else { if (hasCurrentParameterValue) { acceptedValues.Add (parameterName, currentParameterValue); } } } // Add all remaining new values to the list of values we will use for URL generation foreach (var newValue in values) { if (ParameterIsNonEmpty (newValue.Value) && !acceptedValues.ContainsKey (newValue.Key)) { acceptedValues.Add (newValue.Key, newValue.Value); } } // Add all current values that aren't in the URL at all foreach (var currentValue in currentValues) { if (!acceptedValues.ContainsKey (currentValue.Key) && !parameterNames.ContainsKey (currentValue.Key)) { acceptedValues.Add (currentValue.Key, currentValue.Value); } } // Add all remaining default values from the route to the list of values we will use for URL generation foreach (var item in parameterNames) { object defaultValue; if (!acceptedValues.ContainsKey (item.Key) && !IsParameterRequired (item.Key, defaultValues, out defaultValue)) { // Add the default value only if there isn't already a new value for it and // only if it actually has a default value, which we determine based on whether // the parameter value is required. acceptedValues.Add (item.Key, defaultValue); } } // All required parameters in this URL must have values from somewhere (i.e. the accepted values) foreach (var item in parameterNames) { object defaultValue; if (IsParameterRequired (item.Key, defaultValues, out defaultValue) && !acceptedValues.ContainsKey (item.Key)) { // If the route parameter value is required that means there's // no default value, so if there wasn't a new value for it // either, this route won't match. return null; } } // All other default values must match if they are explicitly defined in the new values var otherDefaultValues = new RouteValueDictionary (defaultValues); foreach (var item in parameterNames) { otherDefaultValues.Remove (item.Key); } foreach (var defaultValue in otherDefaultValues) { object value; if (values.TryGetValue (defaultValue.Key, out value)) { unusedNewValues.Remove (defaultValue.Key); if (!ParametersAreEqual (value, defaultValue.Value)) { // If there is a non-parameterized value in the route and there is a // new value for it and it doesn't match, this route won't match. return null; } } } #endregion #region Step 2: If the route is a match generate the appropriate URL var uri = new StringBuilder (); var pendingParts = new StringBuilder (); var pendingPartsAreAllSafe = false; bool blockAllUriAppends = false; var allSegments = new List (); // Build a list of segments plus separators we can use as template. foreach (var segment in segments) { if (allSegments.Count > 0) allSegments.Add (null); // separator exposed as null. allSegments.Add (segment); } // Finally loop thru al segment-templates building the actual uri. foreach (var item in allSegments) { var segment = item.GetValueOrDefault (); // If segment is a separator.. if (item == null) { if (pendingPartsAreAllSafe) { // Accept if (pendingParts.Length > 0) { if (blockAllUriAppends) return null; // Append any pending literals to the URL uri.Append (pendingParts.ToString ()); pendingParts.Length = 0; } } pendingPartsAreAllSafe = false; // Guard against appending multiple separators for empty segments if (pendingParts.Length > 0 && pendingParts[pendingParts.Length - 1] == '/') { // Dev10 676725: Route should not be matched if that causes mismatched tokens // Dev11 86819: We will allow empty matches if all subsequent segments are null if (blockAllUriAppends) return null; // Append any pending literals to the URI (without the trailing slash) and prevent any future appends uri.Append(pendingParts.ToString (0, pendingParts.Length - 1)); pendingParts.Length = 0; } else { pendingParts.Append ("/"); } #if false } else if (segment.AllLiteral) { // Spezial (optimized) case: all elements of segment are literals. pendingPartsAreAllSafe = true; foreach (var tk in segment.Tokens) pendingParts.Append (tk.Name); #endif } else { // Segments are treated as all-or-none. We should never output a partial segment. // If we add any subsegment of this segment to the generated URL, we have to add // the complete match. For example, if the subsegment is "{p1}-{p2}.xml" and we // used a value for {p1}, we have to output the entire segment up to the next "/". // Otherwise we could end up with the partial segment "v1" instead of the entire // segment "v1-v2.xml". bool addedAnySubsegments = false; foreach (var token in segment.Tokens) { if (token.Type == PatternTokenType.Literal) { // If it's a literal we hold on to it until we are sure we need to add it pendingPartsAreAllSafe = true; pendingParts.Append (token.Name); } else { if (token.Type == PatternTokenType.Standard || token.Type == PatternTokenType.CatchAll) { if (pendingPartsAreAllSafe) { // Accept if (pendingParts.Length > 0) { if (blockAllUriAppends) return null; // Append any pending literals to the URL uri.Append (pendingParts.ToString ()); pendingParts.Length = 0; addedAnySubsegments = true; } } pendingPartsAreAllSafe = false; // If it's a parameter, get its value object acceptedParameterValue; bool hasAcceptedParameterValue = acceptedValues.TryGetValue (token.Name, out acceptedParameterValue); if (hasAcceptedParameterValue) unusedNewValues.Remove (token.Name); object defaultParameterValue; defaultValues.TryGetValue (token.Name, out defaultParameterValue); if (ParametersAreEqual (acceptedParameterValue, defaultParameterValue)) { // If the accepted value is the same as the default value, mark it as pending since // we won't necessarily add it to the URL we generate. pendingParts.Append (Convert.ToString (acceptedParameterValue, CultureInfo.InvariantCulture)); } else { if (blockAllUriAppends) return null; // Add the new part to the URL as well as any pending parts if (pendingParts.Length > 0) { // Append any pending literals to the URL uri.Append (pendingParts.ToString ()); pendingParts.Length = 0; } uri.Append (Convert.ToString (acceptedParameterValue, CultureInfo.InvariantCulture)); addedAnySubsegments = true; } } else { Debug.Fail ("Invalid path subsegment type"); } } } if (addedAnySubsegments) { // See comment above about why we add the pending parts if (pendingParts.Length > 0) { if (blockAllUriAppends) return null; // Append any pending literals to the URL uri.Append (pendingParts.ToString ()); pendingParts.Length = 0; } } } } if (pendingPartsAreAllSafe) { // Accept if (pendingParts.Length > 0) { if (blockAllUriAppends) return null; // Append any pending literals to the URI uri.Append (pendingParts.ToString ()); } } // Process constraints keys if (constraints != null) { // If there are any constraints, mark all the keys as being used so that we don't // generate query string items for custom constraints that don't appear as parameters // in the URI format. foreach (var constraintsItem in constraints) { unusedNewValues.Remove (constraintsItem.Key); } } // Encode the URI before we append the query string, otherwise we would double encode the query string var encodedUri = new StringBuilder (); encodedUri.Append (UriEncode (uri.ToString ())); uri = encodedUri; // Add remaining new values as query string parameters to the URI if (unusedNewValues.Count > 0) { // Generate the query string bool firstParam = true; foreach (string unusedNewValue in unusedNewValues) { object value; if (acceptedValues.TryGetValue (unusedNewValue, out value)) { uri.Append (firstParam ? '?' : '&'); firstParam = false; uri.Append (Uri.EscapeDataString (unusedNewValue)); uri.Append ('='); uri.Append (Uri.EscapeDataString (Convert.ToString (value, CultureInfo.InvariantCulture))); } } } #endregion usedValues = acceptedValues; return uri.ToString(); } } }