Google analytics script

Latest jQuery CDN with code tiggling.

Wednesday, 12 January 2011

Custom Asp.net MVC route class with catch-all segment anywhere in the URL

Asp.net MVC routing does a fine job with routes that have a finite number of segments. We define them with route URL pattern string. The default provided by the Asp.net MVC project template being {controller}/{action}/{id}. Most web applications can do everything using only this single route definition and many developers don't even think beyond this standard. But sometimes this single route just isn't enough or it's just not acceptable.

A real world example

Think of a web site you're building that has hierarchically organised categories. Like Amazon. We have books, music, electronics, etc. And every top category has sub categories. And so on and so forth. Using default route definition we would access a particular (sub)category simply by: www.domain.com/categories/index/647 That's fine, but it's definitely not human friendly. If we'd change our route URL definition to {controller}/{action}/{id}/{name} this would already be much friendlier: www.domain.com/categories/index/647/web-development That's something similar (not the same, because we're still using action names here) to what Stackoverflow does with it's questions.

But now think of this super human readable web address: www.domain.com/books/computer-internet/web-development/latest This one would display latest web development books. As we can see it defines categories in hierarchical order similar to breadcrumbs. All in human readable format. Doing this kind of routing isn't supported out of the box (because we have an action at the end), but I think we could do better. Let's try and create a route that supports this.

Catch-all route URL segment

For an unknown number of URL segments Asp.net MVC routes understand a so called catch-all route URL segment. The bad thing about it is that it can only be defined as the last segment in route URL pattern definition. Nothing else can be defined after it. So this URL pattern definition is invalid: {*categories}/{action} In this example we could of course put the action upfront {action}/{*categories} but sometimes this just wouldn't be acceptable. What if we'd want to display a book of some category. It definitely wouldn't be human friendly any more. Because we'd first define a book and then all it's categories.

A smart(er) catch-all route objective

If we think of a catch-all segment we can see that it could work in any of these situations: {segment}/{segment}/{*segment}
{segment}/{*segment}/{segment}
{*segment}/{segment}/{segment}
Number of defined segments is finite so all the rest belong to catch-all segment. I don't know why the team hasn't decided to support such situations. Thinking even further we could write a route that would support multiple catch-all parameters, as long as there's at least one static segment between them: {*segment}/{segment}/static_segment/{*segment}/{segment} As long as we can locate the static segment, we can as well know which segment belongs to which route URL pattern segment variable. But this is out of our scope here. All I'm saying that it could be done just as well.

URL processing

We will support a single be-anywhere catch-all segment in this scenario. URL path processing should therefore work as follows:

  1. Split incoming address into several segments.
  2. Look at route URL pattern definition and take the first segment.
  3. If it's a normal one, get it's value.
  4. Continue to next segment and repeat until you get to catch-all segment.
  5. Reverse parsing and repeat from the back until you get to catch-all segment again.
  6. All remaining segments belong to this catch-all segment.
  7. Voila. Done. Route parsed.
Sounds simple? Well it is, except that there's a bit more to it when writing a custom route. We have to support incoming as well as outgoing paths. Outgoing ones are used when we call Html.ActionLink() in our views or return RedirectToAction() in our controller (and some others as well). These actually return a particular URL that's generated by a matching route definition.

Writing a custom Route class

When we want to write a custom route we normally inherit it directly from Route class to use its rich functionality. But we'll have to override two methods GetRouteData() and GetVirtualPath(). The first one handles incoming URL handling, and the second one generates a URL based on data provided (outgoing URL handling). When parameters don't match this route definition both methods should return null to inform routing that a particular URL or route data don't relate to this route definition and should ask a different route whether it can process it.

   1:  using System.Collections.Generic;
   2:  using System.Globalization;
   3:  using System.Linq;
   4:  using System.Text;
   5:  using System.Text.RegularExpressions;
   6:   
   7:  namespace System.Web.Routing
   8:  {
   9:      /// <summary>
  10:      /// This route is used for cases where we want greedy route segments anywhere in the route URL definition
  11:      /// </summary>
  12:      public class GreedyRoute : Route
  13:      {
  14:          #region Properties
  15:   
  16:          /// <summary>Gets the URL pattern for the route.</summary>
  17:          public new string Url { get; private set; }
  18:   
  19:          private LinkedList<GreedyRouteSegment> urlSegments = new LinkedList<GreedyRouteSegment>();
  20:   
  21:          private bool hasGreedySegment = false;
  22:   
  23:          /// <summary>Gets minimum number of segments that this route requires.</summary>
  24:          public int MinRequiredSegments { get; private set; }
  25:   
  26:          #endregion
  27:   
  28:          #region Constructors
  29:   
  30:          /// <summary>
  31:          /// Initializes a new instance of the <see cref="GreedyRoute"/> class, using the specified URL pattern and handler class.
  32:          /// </summary>
  33:          /// <param name="url">The URL pattern for the route.</param>
  34:          /// <param name="routeHandler">The object that processes requests for the route.</param>
  35:          public GreedyRoute(string url, IRouteHandler routeHandler)
  36:              : this(url, null, null, null, routeHandler)
  37:          {
  38:          }
  39:   
  40:          /// <summary>
  41:          /// Initializes a new instance of the <see cref="GreedyRoute"/> class, using the specified URL pattern, handler class, and default parameter values.
  42:          /// </summary>
  43:          /// <param name="url">The URL pattern for the route.</param>
  44:          /// <param name="defaults">The values to use if the URL does not contain all the parameters.</param>
  45:          /// <param name="routeHandler">The object that processes requests for the route.</param>
  46:          public GreedyRoute(string url, RouteValueDictionary defaults, IRouteHandler routeHandler)
  47:              : this(url, defaults, null, null, routeHandler)
  48:          {
  49:          }
  50:   
  51:          /// <summary>
  52:          /// Initializes a new instance of the <see cref="GreedyRoute"/> class, using the specified URL pattern, handler class, default parameter values, and constraints.
  53:          /// </summary>
  54:          /// <param name="url">The URL pattern for the route.</param>
  55:          /// <param name="defaults">The values to use if the URL does not contain all the parameters.</param>
  56:          /// <param name="constraints">A regular expression that specifies valid values for a URL parameter.</param>
  57:          /// <param name="routeHandler">The object that processes requests for the route.</param>
  58:          public GreedyRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, IRouteHandler routeHandler)
  59:              : this(url, defaults, constraints, null, routeHandler)
  60:          {
  61:          }
  62:   
  63:          /// <summary>
  64:          /// Initializes a new instance of the <see cref="GreedyRoute"/> class, using the specified URL pattern, handler class, default parameter values, constraints, and custom values.
  65:          /// </summary>
  66:          /// <param name="url">The URL pattern for the route.</param>
  67:          /// <param name="defaults">The values to use if the URL does not contain all the parameters.</param>
  68:          /// <param name="constraints">A regular expression that specifies valid values for a URL parameter.</param>
  69:          /// <param name="dataTokens">Custom values that are passed to the route handler, but which are not used to determine whether the route matches a specific URL pattern. The route handler might need these values to process the request.</param>
  70:          /// <param name="routeHandler">The object that processes requests for the route.</param>
  71:          public GreedyRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, RouteValueDictionary dataTokens, IRouteHandler routeHandler)
  72:              : base(url.Replace("*", ""), defaults, constraints, dataTokens, routeHandler)
  73:          {
  74:              this.Defaults = defaults ?? new RouteValueDictionary();
  75:              this.Constraints = constraints;
  76:              this.DataTokens = dataTokens;
  77:              this.RouteHandler = routeHandler;
  78:              this.Url = url;
  79:              this.MinRequiredSegments = 0;
  80:   
  81:              // URL must be defined
  82:              if (string.IsNullOrEmpty(url))
  83:              {
  84:                  throw new ArgumentException("Route URL must be defined.", "url");
  85:              }
  86:   
  87:              // correct URL definition can have AT MOST ONE greedy segment
  88:              if (url.Split('*').Length > 2)
  89:              {
  90:                  throw new ArgumentException("Route URL can have at most one greedy segment, but not more.", "url");
  91:              }
  92:   
  93:              Regex rx = new Regex(@"^(?<isToken>{)?(?(isToken)(?<isGreedy>\*?))(?<name>[a-zA-Z0-9-_]+)(?(isToken)})$", RegexOptions.Compiled | RegexOptions.Singleline);
  94:              foreach (string segment in url.Split('/'))
  95:              {
  96:                  // segment must not be empty
  97:                  if (string.IsNullOrEmpty(segment))
  98:                  {
  99:                      throw new ArgumentException("Route URL is invalid. Sequence \"//\" is not allowed.", "url");
 100:                  }
 101:   
 102:                  if (rx.IsMatch(segment))
 103:                  {
 104:                      Match m = rx.Match(segment);
 105:                      GreedyRouteSegment s = new GreedyRouteSegment {
 106:                          IsToken = m.Groups["isToken"].Value.Length.Equals(1),
 107:                          IsGreedy = m.Groups["isGreedy"].Value.Length.Equals(1),
 108:                          Name = m.Groups["name"].Value
 109:                      };
 110:                      this.urlSegments.AddLast(s);
 111:                      this.hasGreedySegment |= s.IsGreedy;
 112:   
 113:                      continue;
 114:                  }
 115:                  throw new ArgumentException("Route URL is invalid.", "url");
 116:              }
 117:   
 118:              // get minimum required segments for this route
 119:              LinkedListNode<GreedyRouteSegment> seg = this.urlSegments.Last;
 120:              int sIndex = this.urlSegments.Count;
 121:              while (seg != null && this.MinRequiredSegments.Equals(0))
 122:              {
 123:                  if (!seg.Value.IsToken || !this.Defaults.ContainsKey(seg.Value.Name))
 124:                  {
 125:                      this.MinRequiredSegments = Math.Max(this.MinRequiredSegments, sIndex);
 126:                  }
 127:                  sIndex--;
 128:                  seg = seg.Previous;
 129:              }
 130:   
 131:              // check that segments after greedy segment don't define a default
 132:              if (this.hasGreedySegment)
 133:              {
 134:                  LinkedListNode<GreedyRouteSegment> s = this.urlSegments.Last;
 135:                  while (s != null && !s.Value.IsGreedy)
 136:                  {
 137:                      if (s.Value.IsToken && this.Defaults.ContainsKey(s.Value.Name))
 138:                      {
 139:                          throw new ArgumentException(string.Format("Defaults for route segment \"{0}\" is not allowed, because it's specified after greedy catch-all segment.", s.Value.Name), "defaults");
 140:                      }
 141:                      s = s.Previous;
 142:                  }
 143:              }
 144:          }
 145:   
 146:          #endregion
 147:   
 148:          #region GetRouteData
 149:          /// <summary>
 150:          /// Returns information about the requested route.
 151:          /// </summary>
 152:          /// <param name="httpContext">An object that encapsulates information about the HTTP request.</param>
 153:          /// <returns>
 154:          /// An object that contains the values from the route definition.
 155:          /// </returns>
 156:          public override RouteData GetRouteData(HttpContextBase httpContext)
 157:          {
 158:              string virtualPath = httpContext.Request.AppRelativeCurrentExecutionFilePath.Substring(2) + (httpContext.Request.PathInfo ?? string.Empty);
 159:   
 160:              RouteValueDictionary values = this.ParseRoute(virtualPath);
 161:              if (values == null)
 162:              {
 163:                  return null;
 164:              }
 165:   
 166:              RouteData result = new RouteData(this, this.RouteHandler);
 167:              if (!this.ProcessConstraints(httpContext, values, RouteDirection.IncomingRequest))
 168:              {
 169:                  return null;
 170:              }
 171:   
 172:              // everything's fine, fill route data
 173:              foreach (KeyValuePair<string, object> value in values)
 174:              {
 175:                  result.Values.Add(value.Key, value.Value);
 176:              }
 177:              if (this.DataTokens != null)
 178:              {
 179:                  foreach (KeyValuePair<string, object> token in this.DataTokens)
 180:                  {
 181:                      result.DataTokens.Add(token.Key, token.Value);
 182:                  }
 183:              }
 184:              return result;
 185:          }
 186:          #endregion
 187:   
 188:          #region GetVirtualPath
 189:          /// <summary>
 190:          /// Returns information about the URL that is associated with the route.
 191:          /// </summary>
 192:          /// <param name="requestContext">An object that encapsulates information about the requested route.</param>
 193:          /// <param name="values">An object that contains the parameters for a route.</param>
 194:          /// <returns>
 195:          /// An object that contains information about the URL that is associated with the route.
 196:          /// </returns>
 197:          public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
 198:          {
 199:              RouteUrl url = this.Bind(requestContext.RouteData.Values, values);
 200:              if (url == null)
 201:              {
 202:                  return null;
 203:              }
 204:              if (!this.ProcessConstraints(requestContext.HttpContext, url.Values, RouteDirection.UrlGeneration))
 205:              {
 206:                  return null;
 207:              }
 208:   
 209:              VirtualPathData data = new VirtualPathData(this, url.Url);
 210:              if (this.DataTokens != null)
 211:              {
 212:                  foreach (KeyValuePair<string, object> pair in this.DataTokens)
 213:                  {
 214:                      data.DataTokens[pair.Key] = pair.Value;
 215:                  }
 216:              }
 217:              return data;
 218:          }
 219:          #endregion
 220:   
 221:          #region Private methods
 222:   
 223:          #region ProcessConstraints
 224:          /// <summary>
 225:          /// Processes constraints.
 226:          /// </summary>
 227:          /// <param name="httpContext">The HTTP context.</param>
 228:          /// <param name="values">Route values.</param>
 229:          /// <param name="direction">Route direction.</param>
 230:          /// <returns><c>true</c> if constraints are satisfied; otherwise, <c>false</c>.</returns>
 231:          private bool ProcessConstraints(HttpContextBase httpContext, RouteValueDictionary values, RouteDirection direction)
 232:          {
 233:              if (this.Constraints != null)
 234:              {
 235:                  foreach (KeyValuePair<string, object> constraint in this.Constraints)
 236:                  {
 237:                      if (!this.ProcessConstraint(httpContext, constraint.Value, constraint.Key, values, direction))
 238:                      {
 239:                          return false;
 240:                      }
 241:                  }
 242:              }
 243:              return true;
 244:          }
 245:          #endregion
 246:   
 247:          #region ParseRoute
 248:          /// <summary>
 249:          /// Parses the route into segment data as defined by this route.
 250:          /// </summary>
 251:          /// <param name="virtualPath">Virtual path.</param>
 252:          /// <returns>Returns <see cref="System.Web.Routing.RouteValueDictionary"/> dictionary of route values.</returns>
 253:          private RouteValueDictionary ParseRoute(string virtualPath)
 254:          {
 255:              Stack<string> parts = new Stack<string>(
 256:                  virtualPath
 257:                  .Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries)
 258:                  .Reverse() // we have to reverse it because parsing starts at the beginning not the end.
 259:              );
 260:   
 261:              // number of request route parts must match route URL definition
 262:              if (parts.Count < this.MinRequiredSegments)
 263:              {
 264:                  return null;
 265:              }
 266:   
 267:              RouteValueDictionary result = new RouteValueDictionary();
 268:   
 269:              // start parsing from the beginning
 270:              bool finished = false;
 271:              LinkedListNode<GreedyRouteSegment> currentSegment = this.urlSegments.First;
 272:              while (!finished && !currentSegment.Value.IsGreedy)
 273:              {
 274:                  object p = parts.Count > 0 ? parts.Pop() : null;
 275:                  if (currentSegment.Value.IsToken)
 276:                  {
 277:                      p = p ?? this.Defaults[currentSegment.Value.Name];
 278:                      result.Add(currentSegment.Value.Name, p);
 279:                  }
 280:                  else
 281:                  {
 282:                      if (!currentSegment.Value.Name.Equals(p))
 283:                      {
 284:                          return null;
 285:                      }
 286:                  }
 287:                  currentSegment = currentSegment.Next;
 288:                  finished = currentSegment == null;
 289:              }
 290:   
 291:              // continue from the end if needed
 292:              parts = new Stack<string>(parts); // this will reverse stack elements
 293:              currentSegment = this.urlSegments.Last;
 294:              while (!finished && !currentSegment.Value.IsGreedy)
 295:              {
 296:                  object p = parts.Count > 0 ? parts.Pop() : null;
 297:                  if (currentSegment.Value.IsToken)
 298:                  {
 299:                      p = p ?? this.Defaults[currentSegment.Value.Name];
 300:                      result.Add(currentSegment.Value.Name, p);
 301:                  }
 302:                  else
 303:                  {
 304:                      if (!currentSegment.Value.Name.Equals(p))
 305:                      {
 306:                          return null;
 307:                      }
 308:                  }
 309:                  currentSegment = currentSegment.Previous;
 310:                  finished = currentSegment == null;
 311:              }
 312:   
 313:              // fill in the greedy catch-all segment
 314:              if (!finished)
 315:              {
 316:                  object remaining = string.Join("/", parts.Reverse().ToArray()) ?? this.Defaults[currentSegment.Value.Name];
 317:                  result.Add(currentSegment.Value.Name, remaining);
 318:              }
 319:   
 320:              // add remaining default values
 321:              foreach (KeyValuePair<string, object> def in this.Defaults)
 322:              {
 323:                  if (!result.ContainsKey(def.Key))
 324:                  {
 325:                      result.Add(def.Key, def.Value);
 326:                  }
 327:              }
 328:   
 329:              return result;
 330:          }
 331:          #endregion
 332:   
 333:          #region Bind
 334:          /// <summary>
 335:          /// Binds the specified current values and values into a URL.
 336:          /// </summary>
 337:          /// <param name="currentValues">Current route data values.</param>
 338:          /// <param name="values">Additional route values that can be used to generate the URL.</param>
 339:          /// <returns>Returns a URL route string.</returns>
 340:          private RouteUrl Bind(RouteValueDictionary currentValues, RouteValueDictionary values)
 341:          {
 342:              currentValues = currentValues ?? new RouteValueDictionary();
 343:              values = values ?? new RouteValueDictionary();
 344:   
 345:              HashSet<string> required = new HashSet<string>(this.urlSegments.Where(seg => seg.IsToken).ToList().ConvertAll(seg => seg.Name), StringComparer.OrdinalIgnoreCase);
 346:              RouteValueDictionary routeValues = new RouteValueDictionary();
 347:   
 348:              object dataValue = null;
 349:              foreach (string token in new List<string>(required))
 350:              {
 351:                  dataValue = values[token] ?? currentValues[token] ?? this.Defaults[token];
 352:                  if (this.IsUsable(dataValue))
 353:                  {
 354:                      string val = dataValue as string;
 355:                      if (val != null)
 356:                      {
 357:                          val = val.StartsWith("/") ? val.Substring(1) : val;
 358:                          val = val.EndsWith("/") ? val.Substring(0, val.Length - 1) : val;
 359:                      }
 360:                      routeValues.Add(token, val ?? dataValue);
 361:                      required.Remove(token);
 362:                  }
 363:              }
 364:   
 365:              // this route data is not related to this route
 366:              if (required.Count > 0)
 367:              {
 368:                  return null;
 369:              }
 370:   
 371:              // add all remaining values
 372:              foreach (KeyValuePair<string, object> pair1 in values)
 373:              {
 374:                  if (this.IsUsable(pair1.Value) && !routeValues.ContainsKey(pair1.Key))
 375:                  {
 376:                      routeValues.Add(pair1.Key, pair1.Value);
 377:                  }
 378:              }
 379:   
 380:              // add remaining defaults
 381:              foreach (KeyValuePair<string, object> pair2 in this.Defaults)
 382:              {
 383:                  if (this.IsUsable(pair2.Value) && !routeValues.ContainsKey(pair2.Key))
 384:                  {
 385:                      routeValues.Add(pair2.Key, pair2.Value);
 386:                  }
 387:              }
 388:   
 389:              // check that non-segment defaults are the same as those provided
 390:              RouteValueDictionary nonRouteDefaults = new RouteValueDictionary(this.Defaults);
 391:              foreach (GreedyRouteSegment seg in this.urlSegments.Where(ss => ss.IsToken))
 392:              {
 393:                  nonRouteDefaults.Remove(seg.Name);
 394:              }
 395:              foreach (KeyValuePair<string, object> pair3 in nonRouteDefaults)
 396:              {
 397:                  if (!routeValues.ContainsKey(pair3.Key) || !this.RoutePartsEqual(pair3.Value, routeValues[pair3.Key]))
 398:                  {
 399:                      // route data is not related to this route
 400:                      return null;
 401:                  }
 402:              }
 403:   
 404:              StringBuilder sb = new StringBuilder();
 405:              RouteValueDictionary valuesToUse = new RouteValueDictionary(routeValues);
 406:              bool mustAdd = this.hasGreedySegment;
 407:   
 408:              // build URL string
 409:              LinkedListNode<GreedyRouteSegment> s = this.urlSegments.Last;
 410:              object segmentValue = null;
 411:              while (s != null)
 412:              {
 413:                  if (s.Value.IsToken)
 414:                  {
 415:                      segmentValue = valuesToUse[s.Value.Name];
 416:                      mustAdd = mustAdd || !this.RoutePartsEqual(segmentValue, this.Defaults[s.Value.Name]);
 417:                      valuesToUse.Remove(s.Value.Name);
 418:                  }
 419:                  else
 420:                  {
 421:                      segmentValue = s.Value.Name;
 422:                      mustAdd = true;
 423:                  }
 424:   
 425:                  if (mustAdd)
 426:                  {
 427:                      sb.Insert(0, sb.Length > 0 ? "/" : string.Empty);
 428:                      sb.Insert(0, Uri.EscapeUriString(Convert.ToString(segmentValue, CultureInfo.InvariantCulture)));
 429:                  }
 430:   
 431:                  s = s.Previous;
 432:              }
 433:   
 434:              // add remaining values
 435:              if (valuesToUse.Count > 0)
 436:              {
 437:                  bool first = true;
 438:                  foreach (KeyValuePair<string, object> pair3 in valuesToUse)
 439:                  {
 440:                      // only add when different from defaults
 441:                      if (!this.RoutePartsEqual(pair3.Value, this.Defaults[pair3.Key]))
 442:                      {
 443:                          sb.Append(first ? "?" : "&");
 444:                          sb.Append(Uri.EscapeDataString(pair3.Key));
 445:                          sb.Append("=");
 446:                          sb.Append(Uri.EscapeDataString(Convert.ToString(pair3.Value, CultureInfo.InvariantCulture)));
 447:                          first = false;
 448:                      }
 449:                  }
 450:              }
 451:   
 452:              return new RouteUrl {
 453:                  Url = sb.ToString(),
 454:                  Values = routeValues
 455:              };
 456:          }
 457:          #endregion
 458:   
 459:          #region IsUsable
 460:          /// <summary>
 461:          /// Determines whether an object actually is instantiated or has a value.
 462:          /// </summary>
 463:          /// <param name="value">Object value to check.</param>
 464:          /// <returns>
 465:          ///     <c>true</c> if an object is instantiated or has a value; otherwise, <c>false</c>.
 466:          /// </returns>
 467:          private bool IsUsable(object value)
 468:          {
 469:              string val = value as string;
 470:              if (val != null)
 471:              {
 472:                  return val.Length > 0;
 473:              }
 474:              return value != null;
 475:          }
 476:          #endregion
 477:   
 478:          #region RoutePartsEqual
 479:          /// <summary>
 480:          /// Checks if two route parts are equal
 481:          /// </summary>
 482:          /// <param name="firstValue">The first value.</param>
 483:          /// <param name="secondValue">The second value.</param>
 484:          /// <returns><c>true</c> if both values are equal; otherwise, <c>false</c>.</returns>
 485:          private bool RoutePartsEqual(object firstValue, object secondValue)
 486:          {
 487:              string sFirst = firstValue as string;
 488:              string sSecond = secondValue as string;
 489:              if ((sFirst != null) && (sSecond != null))
 490:              {
 491:                  return string.Equals(sFirst, sSecond, StringComparison.OrdinalIgnoreCase);
 492:              }
 493:              if ((firstValue != null) && (secondValue != null))
 494:              {
 495:                  return firstValue.Equals(secondValue);
 496:              }
 497:              return (firstValue == secondValue);
 498:          }
 499:          #endregion
 500:   
 501:          #endregion
 502:      }
 503:  }

In the upper route class we also use two additional custom classes GreedyRouteSegment and RouteUrl. They are very simple POCO classes as follows.

   1:  /// <summary>
   2:  /// Represents a route segment that may as well be greedy (catch-all).
   3:  /// </summary>
   4:  public class GreedyRouteSegment
   5:  {
   6:      /// <summary>
   7:      /// Gets or sets segment path or token name.
   8:      /// </summary>
   9:      /// <value>Route segment path or token name.</value>
  10:      public string Name { get; set; }
  11:   
  12:      /// <summary>
  13:      /// Gets or sets a value indicating whether this segment is greedy.
  14:      /// </summary>
  15:      /// <value><c>true</c> if this segment is greedy; otherwise, <c>false</c>.</value>
  16:      public bool IsGreedy { get; set; }
  17:   
  18:      /// <summary>
  19:      /// Gets or sets a value indicating whether this segment is a token.
  20:      /// </summary>
  21:      /// <value><c>true</c> if this segment is a token; otherwise, <c>false</c>.</value>
  22:      public bool IsToken { get; set; }
  23:  }
   1:  /// <summary>
   2:  /// Represents a generated route URL with route data.
   3:  /// </summary>
   4:  public class RouteUrl
   5:  {
   6:      /// <summary>
   7:      /// Gets or sets the route URL.
   8:      /// </summary>
   9:      /// <value>Route URL.</value>
  10:      public string Url { get; set; }
  11:   
  12:      /// <summary>
  13:      /// Gets or sets route values.
  14:      /// </summary>
  15:      /// <value>Route values.</value>
  16:      public RouteValueDictionary Values { get; set; }
  17:  }

Requirements and usage

This route works pretty much the same as standard route, you're using all the time. But there's one most significant difference when it comes to generating URL. When a catch-all parameter is not the last one, all remaining segments will be added to the URL address even when they have default values. Default route omits these because a certain URL can still match a particular route. But when we have a catch-all segment somewhere in the middle, we have to provide the rest regardless of their default value or not. But you shouldn't worry, because constructor takes care of such situations. Whenever you want to define a default for a segment defined after the catch-all one, you'll get an exception explaining the problem. The best part is that you don't have to worry about this, because route will corectly generate URLs so they will be matched without a problem.

The remaining information is related to this custom route usage. Usually we use RouteCollection's extension method MapRoute that creates a new instance of the default Route class. So in case we'd like to use our GreedyRoute class, we have to either:

  • write our own extension like MapGreedyRoute that would create it or
  • use this code instead: routes.Add(new GreedyRoute(...))
And don't worry about mixing default Route class instances and GreedyRoute class instances. We can define as many routes as we want and they can be of any class. So use default route always when it does its job and use GreedyRoute whenever you have a requirement of a tricky catch-all segment. And that's it really.

Possible improvements

One of the possible (and most obvious) improvements could be to keep all catch-all segments as a split collection of values instead of joining them back together. The same could be done when creating the URL. Catch-all value could be provided as a collection. Doing this would remove the unnecessary step of manual string parsing in our controller action. We could just iterate over the provided collection. but for the time being this works very much the same as default Asp.net MVC catch-all segment routing.

Questions? Bugs?

If you have any further questions or have found any bugs in my code, you're more than welcome to tell me. Just ad a comment at the end of this post and I'll take a look. If you have any other questions or improvement ideas, I'll listen as well.

27 comments:

  1. Hi Robert,

    Thanks for your work on this.

    I'm trying to use your code but either it's not working correctly or I'm trying to use it in a way which is not supported. I suspect the latter.

    Should it be able to match the following:

    url: hotels/uk/london/victoria/rating/2
    pattern: hotels/{*location}/rating/{rating}
    expected: location=uk/london/victoria, rating=2

    cheers

    Pete

    ReplyDelete
  2. @Peter: Can you please provide C# code of your routes registration (all of them if you define several routes)?

    ReplyDelete
  3. Hi Robert,

    Here are the routes I have defined:

    routes.Add(null, new GreedyRoute("hotels/{*location}/rating/{rating}", new RouteValueDictionary(new { contoller = "search", action = "rating" }), new MvcRouteHandler()));


    routes.MapRoute(null, "{controller}.mvc/{action}/{id}", new { controller = "Home", action = "Index", id = UrlParameter.Optional });

    The Custom Route is running but when this part of the code executes:

    // start parsing from the beginning
    bool finished = false;
    LinkedListNode currentSegment = this.urlSegments.First;
    while (!finished && !currentSegment.Value.IsGreedy)
    {
    object p = parts.Pop();
    if (currentSegment.Value.IsToken)
    {
    p = p ?? this.Defaults[currentSegment.Value.Name];
    result.Add(currentSegment.Value.Name, p);
    currentSegment = currentSegment.Next;
    finished = currentSegment == null;
    continue;
    }
    // This is the part that is getting executed.
    if (!currentSegment.Value.Equals(p))
    {
    return null;
    }
    }

    It compares "hotel" with "2" and returns null.

    Cheers

    Pete

    ReplyDelete
  4. @Pete: Let me write some unit tests, because I may have provided a pre-release code (a.k.a. not working as expected). It surely seems there's a bug. I'll write some tests and update code afterwards, so you'll be able to reliably use it.

    You're welcome to check back later.

    ReplyDelete
  5. Hi Robert,

    I'm using your code to help me write an updated Route Class that supports multiple wildcards.

    Once I'm done would you mind reviewing it? I just want to be sure that there's no obvious issues.

    Cheers

    Pete

    ReplyDelete
  6. @Pete: I've written some tests for GetRouteDate() method and it now seems to work as expected.

    By multiple wildcards do you mean, you're writing a route class that can have multiple catch-all (greedy) segments?

    When you're done, send me the code on my email, because it would be a bit lengthy to paste it here as a comment.

    IMPORTANT
    Don't copy this email address because it's obfuscated. You'll have to manually type it in your email client:
    moc.liamg@kintirok.trebor

    ReplyDelete
  7. Hey Robert,

    I've emailed you what I've done so far.

    Cheers

    Pete

    ReplyDelete
  8. Robert Hi,
    I'm perinatologist. I have a default.aspx driven by database(hierarchic).I want to convert requested url from something like www.mysite/Obstetrics/Delivery/Stages/Dilation to something like default.aspx?PageId=12&Stage='Dilation'.
    Could you explain how can I use your greedyrouting class?

    ReplyDelete
    Replies
    1. Dear Anonymous.

      The answer to your question is: you highly likely don't need my class.

      1. If you actually don't have any content at requested address, you're more likely after URL Rewriting which is part of IIS (as an installable module) and is rather powerful but you need some syntax learning.
      People usually do the opposite when they want to change a meaningless URL into something more human readable. But you're trying to do the opposite. Anyway. Check it out.

      2. Are you sure you're writing an Asp.net MVC application, because I suspect it's a WebForms one... (the one with <asp:Button runat="server" ID="...">... and the like... If that's the case I can't really help you, because I've never used routing in a WebForms app. You'll have to look it up elsewhere or ask a question on Stackoverflow.

      Delete
    2. Yes it is a webform application not mvc.
      Thank you very much for quick answer.

      Delete
  9. This sounds great....I am having a problem though. I am using a custom RouteHandler. I am trying to allow for a anchor(#) reference in a URL.

    See below:

    routes.MapRoute(
    "Default",
    "{*FriendlyUrl}"
    ).RouteHandler = new UrlRewriteMVC();


    routes.Add(
    "Custom",
    new GreedyRoute(
    "{*FriendlyUrl}#{anchor}",
    new UrlRewriteMVC()
    )
    );

    Funny thing is when I first compile, I will get an error: Route URL is invalid.

    If I refresh, it works fine, and the anchor(#) reference works. Every time I compile though I will get the same error.

    So I push to my own live environment, and sure enough, I get the same error. Refreshing this time does nothing.

    Any ideas?

    ReplyDelete
    Replies
    1. I'm not sure why refreshing on your machine works. It shouldn't. URD pattern matching fails as per my code because I only allow "/" to be route segment delimiter. Hashes or any other character are not recognised as delimiters.

      What you could try is to change line 94 that splits segments by "/". You should split it by hash as well. By the first look this is it although this may have other implications, but try that and then go along as you get an error (if you do).

      Delete
  10. This comment has been removed by the author.

    ReplyDelete
  11. Hi, this looks fantastic, something I have been needing for my application - I just had one question, would your class work with areas, and namespaces? There isn't an overload that include the namespace parameter..

    Thanks.

    ReplyDelete
    Replies
    1. Hi Greg,

      There isn't any such overload, because I didn't need it. But feel free to write your own extension method to add namespace. Although MVC checks all possible namespaces of all available assemblies for controllers anyway, so your controllers should be discovered in any case.

      But regarding areas is a different story. I suppose you should try it out. Just quickly glancing over my old code I think it may work, but I'm not 100% sure. Try it out and come back with the outcome or problems you may come across.

      Delete
    2. Hi Robert,

      Just to let you know I wrote some unit tests around your class and it is catering for all my scenarios without any problems.

      Thanks so much for your effort.

      I do find though that in the case where I am using HtmlHelper.BeginForm, it doesn't seem to build the correct url according to the route? It puts one of the fields at the end.

      "/Admin/Sites/a-website.com/section/my-page/ContentPage"
      becomes
      "/Admin/Sites/a-website.com/section/ContentPage?page=my-page"

      any ideas why it would do this? the unit tests for GetVirtualPath works correctly though. :S

      Delete
    3. Hi Greg.

      Please provide your routing so I can see what's going on. And please try to generate the same URL by using ActionLink. They should both use the same mechanism for generating URLs.

      In your case where a particular URL parameter gets added as a query variable likely simply means that it's been handled by a different route definition that doesn't have the parameter called page. That's the only reason that this can happen. So it must be something wrong with our route definition.

      Delete
    4. This comment has been removed by the author.

      Delete
    5. This comment has been removed by the author.

      Delete
    6. I got it working, I think because I am using areas I was running into this problem...

      http://stackoverflow.com/questions/4602786/how-to-specify-default-area-to-html-buildurlfromexpression-call

      So instead I registered your GreedyRoute with a name.

      Then used

      @using (Html.BeginRouteForm("GreedyRoute", new { .. }, FormMethod.Post, null))

      Instead, now it is all working quite well.

      Thanks again!

      Delete
    7. I can see that you're having custom constraints set on greedy route. I suggest that you put a breakpoint on line 167 ad 204 of the greedy route class and see whether route has been processed as per definition. Because if has been then constraints will fail route matching and you will have to investigate your domain and page constraints classes.

      Delete
    8. I forgot to address the other comment where you linked to stackoverflow question. My answer is this: if you're not using the MVC Futures project then this has nothing to do with it. And I suppose you're not using it.

      Delete
  12. Hello Robert,
    I am interested in the tests you wrote (mainly to learn something)
    Are you willing to make these available?

    Thanks in advance
    Marc

    ReplyDelete
    Replies
    1. I'm sorry to disappoint you but there are no formal tests for this route class. This route class was part of a rather urgent project (which one isn't I know) developed by two developers (myself included).
      All testing was ad-hoc and done during actual application debugging by me. Nothing else...

      Delete
  13. Hi Robert,

    Do you have any idea how to make GreedyRoute for WebApi? They use different set of classes, and even after I rewrote your class to use HttpRoute instead of Route, even though I didn't help. GetRouteData() is not even called at all, so the class work just as a simple Route, not a greedy one.

    ReplyDelete
  14. Hi Robert,

    This is an amazing job !! ... so now I'm trying to use it, but I'm a little lost, I hope you can help me. :)
    In first place I need a route with something like controller/{*categories}/action, I can see that your code it's what I need for achieve this.

    I have registered my new route like this.

    routes.Add(new GreedyRoute("tools/{*categories}/news", new RouteValueDictionary(new { contoller = "tools", action = "news" }), new MvcRouteHandler()));

    How shoul I create the Action Result Method in my controller ?

    public ActionResult news ( ?? )
    {
    //do something
    }

    It's the registered route ok ?
    Does the actionresult need an string[] for incoming parameters ?

    Thanks in advance.




    ReplyDelete