Route Testing Extensions
/// <summary>
/// Used to simplify testing routes and restful testing routes
/// <example>
/// This tests that incoming PUT on resource is handled by the update method of the Banner controller
/// "~/banner/1"
/// .WithMethod(HttpVerbs.Put)
/// .ShouldMapTo<BannerController>(action => action.Update(1));
/// </example>
/// </summary>
public static class RouteTestingExtensions
{
/// <summary>
/// Will return the name of the action specified in the ActionNameAttribute for a method if it has an
/// ActionNameAttribute.
/// Will return the name of the method otherwise.
/// </summary>
/// <param name="method"></param>
/// <returns></returns>
public static string ActionName(this MethodInfo method)
{
return method.IsDecoratedWith<ActionNameAttribute>() ? method.GetAttribute<ActionNameAttribute>().Name : method.Name;
}
/// <summary>
/// Will return true the first attribute of type TAttribute on the attributeTarget.
/// </summary>
/// <typeparam name="TAttribute"></typeparam>
/// <param name="attributeTarget"></param>
/// <returns></returns>
public static TAttribute GetAttribute<TAttribute>(this ICustomAttributeProvider attributeTarget) where TAttribute : Attribute
{
return (TAttribute) attributeTarget.GetCustomAttributes(typeof (TAttribute), false)[0];
}
#region MVC
/// <summary>
/// Gets a value from the <see cref="RouteValueDictionary" /> by key. Does a
/// case-insensitive search on the keys.
/// </summary>
/// <param name="routeValues"></param>
/// <param name="key"></param>
/// <returns></returns>
public static object GetValue(this RouteValueDictionary routeValues, string key)
{
return (from routeValueKey in routeValues.Keys
where string.Equals(routeValueKey, key, StringComparison.InvariantCultureIgnoreCase)
select routeValues[routeValueKey] == null ? null : routeValues[routeValueKey].ToString()).FirstOrDefault();
}
/// <summary>
/// Will return true if the attributeTarget is decorated with an attribute of type TAttribute.
/// Will return false if not.
/// </summary>
/// <typeparam name="TAttribute"></typeparam>
/// <param name="attributeTarget"></param>
/// <returns></returns>
public static bool IsDecoratedWith<TAttribute>(this ICustomAttributeProvider attributeTarget) where TAttribute : Attribute
{
return attributeTarget.GetCustomAttributes(typeof (TAttribute), false).Length > 0;
}
/// <summary>
/// Find the route for a URL and an Http Method
/// because you have a method contraint on the route
/// </summary>
/// <param name="url"></param>
/// <param name="httpMethod"></param>
/// <returns></returns>
public static RouteData Route(string url, string httpMethod)
{
var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build();
return RouteTable.Routes.GetRouteData(context);
}
/// <summary>
/// Returns the corresponding route for the URL. Returns null if no route was found.
/// </summary>
/// <param name="url">The app relative url to test.</param>
/// <returns>A matching <see cref="RouteData" />, or null.</returns>
public static RouteData Route(this string url)
{
var context = new MockHttpContextFixture().WithRelativePath(url).Build();
return RouteTable.Routes.GetRouteData(context);
}
/// <summary>
/// Returns the corresponding route for the URL. Returns null if no route was found.
/// </summary>
/// <param name="url">The URL.</param>
/// <param name="httpMethod">The HTTP method.</param>
/// <param name="formMethod">The form method.</param>
/// <returns></returns>
public static RouteData Route(this string url, HttpVerbs httpMethod, HttpVerbs formMethod)
{
var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build();
var route = RouteTable.Routes.GetRouteData(context);
return route;
}
/// <summary>
/// Returns the corresponding route for the URL. Returns null if no route was found.
/// </summary>
/// <param name="url">The URL.</param>
/// <param name="httpMethod">The HTTP method.</param>
/// <returns></returns>
public static RouteData Route(this string url, HttpVerbs httpMethod)
{
var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build();
var route = RouteTable.Routes.GetRouteData(context);
return route;
}
/// <summary>
/// Verifies the <see cref="RouteData">routeData</see> will instruct the routing engine to ignore the route.
/// </summary>
/// <param name="relativeUrl"></param>
/// <returns></returns>
public static RouteData ShouldBeIgnored(this string relativeUrl)
{
var routeData = relativeUrl.Route();
if (routeData.RouteHandler.GetType() != typeof (PageRouteHandler))
throw new RouteTestingException("Expected StopRoutingHandler, but wasn't");
return routeData;
}
/// <summary>
/// Asserts that the route matches the expression specified. Checks controller, action, and any method arguments
/// into the action as route values.
/// </summary>
/// <typeparam name="TController">The controller.</typeparam>
/// <param name="routeData">The routeData to check</param>
/// <param name="action">The action to call on TController.</param>
public static RouteData ShouldMapTo<TController>(this RouteData routeData, Expression<Func<TController, ActionResult>> action)
where TController : Controller
{
if (RouteTable.Routes.Count == 0)
throw new ArgumentException("No routes found in route table make sure you register routes before testing route matching");
if (routeData == null)
throw new ArgumentException("The URL did not match any route");
//check controller
routeData.ShouldMapTo<TController>();
//check action
var methodCall = CheckActionMaps(routeData, action);
CheckArgumentsMap(routeData, methodCall);
return routeData;
}
/// <summary>
/// Converts the URL to matching RouteData and verifies that it will match a route with the values specified by the
/// expression.
/// </summary>
/// <typeparam name="TController">The type of controller</typeparam>
/// <param name="relativeUrl">The ~/ based url</param>
/// <param name="action">The expression that defines what action gets called (and with which parameters)</param>
/// <returns></returns>
public static RouteData ShouldMapTo<TController>(this string relativeUrl, Expression<Func<TController, ActionResult>> action) where TController : Controller
{
if (!relativeUrl.StartsWith("~"))
throw new InvalidOperationException("Url should be relative and start with a ~");
return relativeUrl.Route().ShouldMapTo(action);
}
/// <summary>
/// Verifies the <see cref="RouteData">routeData</see> maps to the controller type specified.
/// </summary>
/// <typeparam name="TController"></typeparam>
/// <param name="routeData"></param>
/// <returns></returns>
public static RouteData ShouldMapTo<TController>(this RouteData routeData) where TController : Controller
{
//strip out the word 'Controller' from the type
var expected = typeof (TController).Name;
if (expected.EndsWith("Controller"))
{
expected = expected.Substring(0, expected.LastIndexOf("Controller", StringComparison.Ordinal));
}
//get the key (case insensitive)
var actual = routeData.Values.GetValue("controller").ToString();
if (actual != expected)
throw new RouteTestingException(string.Format("Controller names do not match expecting:{0} actual:{1}", expected, actual));
return routeData;
}
/// <summary>
/// Converts the URL to matching RouteData and verifies that it will match a route for a Web Form page.
/// </summary>
/// <param name="relativeUrl">The ~/ based URL</param>
/// <param name="pathToWebForm">The ~/ based path to the web form</param>
/// <returns></returns>
public static RouteData ShouldMapToPage(this string relativeUrl, string pathToWebForm)
{
return relativeUrl.Route().ShouldMapToPage(pathToWebForm);
}
/// <summary>
/// Verifies the <see cref="RouteData">routeData</see> maps to a web form page.
/// </summary>
public static RouteData ShouldMapToPage(this RouteData route, string pathToWebForm)
{
if (route.RouteHandler.GetType() != typeof (PageRouteHandler))
throw new RouteTestingException("The route does not map to a Web Form page.");
var handler = (PageRouteHandler) route.RouteHandler;
if (handler.VirtualPath != pathToWebForm)
throw new RouteTestingException("The route does not map to the correct Web Form page.");
return route;
}
/// <summary>
/// A way to start the fluent interface and and which method to use
/// since you have a method constraint in the route.
/// </summary>
/// <param name="relativeUrl"></param>
/// <param name="httpMethod"></param>
/// <returns></returns>
public static RouteData WithMethod(this string relativeUrl, string httpMethod)
{
return Route(relativeUrl, httpMethod);
}
/// <summary>
/// A way to start the fluent interface and and which method to use
/// since you have a method constraint in the route.
/// </summary>
/// <param name="relativeUrl"></param>
/// <param name="verb"></param>
/// <returns></returns>
public static RouteData WithMethod(this string relativeUrl, HttpVerbs verb)
{
return WithMethod(relativeUrl, verb.ToString("g"));
}
/// <summary>
/// Asserts that the route matches the expression specified based on the incoming HttpMethod and FormMethod for Simply
/// Restful routing. Checks controller, action, and any method arguments
/// into the action as route values.
/// </summary>
/// <param name="relativeUrl">The relative URL.</param>
/// <param name="httpMethod">The HTTP method.</param>
/// <param name="formMethod">The form method.</param>
/// <returns></returns>
public static RouteData WithMethod(this string relativeUrl, HttpVerbs httpMethod, HttpVerbs formMethod)
{
return relativeUrl.Route(httpMethod, formMethod);
}
private static MethodCallExpression CheckActionMaps<TController>(RouteData routeData, Expression<Func<TController, ActionResult>> action) where TController : Controller
{
var methodCall = (MethodCallExpression) action.Body;
var actualAction = routeData.Values.GetValue("action").ToString();
var expectedAction = methodCall.Method.ActionName();
if (!string.Equals(expectedAction, actualAction, StringComparison.InvariantCultureIgnoreCase))
throw new RouteTestingException(string.Format("Action did not match expecting:{0} actual:{1}", expectedAction, actualAction));
return methodCall;
}
private static void CheckArgumentsMap(RouteData routeData, MethodCallExpression methodCall)
{
for (var i = 0; i < methodCall.Arguments.Count; i++)
{
var param = methodCall.Method.GetParameters()[i];
//treat strings as value types
var isReferenceType = !param.ParameterType.IsValueType && param.ParameterType != typeof (string);
var isNullable = isReferenceType ||
(param.ParameterType.UnderlyingSystemType.IsGenericType && param.ParameterType.UnderlyingSystemType.GetGenericTypeDefinition() == typeof (Nullable<>));
var controllerParameterName = param.Name;
var routeDataContainsValueForParameterName = routeData.Values.ContainsKey(controllerParameterName);
var actualValue = routeData.Values.GetValue(controllerParameterName);
object expectedValue = null;
var expressionToEvaluate = methodCall.Arguments[i];
// If the parameter is nullable and the expression is a Convert UnaryExpression,
// we actually want to test against the value of the expression's operand.
if (expressionToEvaluate.NodeType == ExpressionType.Convert
&& expressionToEvaluate is UnaryExpression)
{
expressionToEvaluate = ((UnaryExpression) expressionToEvaluate).Operand;
}
switch (expressionToEvaluate.NodeType)
{
case ExpressionType.Constant:
expectedValue = ((ConstantExpression) expressionToEvaluate).Value;
break;
case ExpressionType.New:
case ExpressionType.MemberAccess:
expectedValue = Expression.Lambda(expressionToEvaluate).Compile().DynamicInvoke();
break;
case ExpressionType.MemberInit:
throw new InvalidOperationException("This method does not support inline implementation of method arguments");
}
// The parameter is nullable so an expected value of '' is equivalent to null;
if (isNullable && actualValue == null && (string) expectedValue == string.Empty
|| (string) actualValue == string.Empty && expectedValue == null)
{
continue;
}
// HACK: this is only sufficient while System.Web.Mvc.UrlParameter has only a single value.
if (actualValue == UrlParameter.Optional ||
(actualValue != null && actualValue.ToString().Equals("System.Web.Mvc.UrlParameter")))
{
actualValue = null;
}
//if its a option param use default value
if (param.IsOptional)
{
actualValue = param.DefaultValue;
}
if (expectedValue is DateTime)
{
actualValue = Convert.ToDateTime(actualValue);
}
else if (isReferenceType)
{
CheckComplexTypeMap(routeData, expectedValue);
}
else
{
//compare as strings
expectedValue = (expectedValue != null) ? expectedValue.ToString() : string.Empty;
actualValue = (actualValue != null) ? actualValue.ToString() : string.Empty;
}
var errorMsgFmt = "Value for parameter '{0}' did not match: expected '{1}' but was '{2}'";
if (routeDataContainsValueForParameterName)
{
errorMsgFmt += ".";
}
else
{
errorMsgFmt += "; no value found in the route context action parameter named '{0}' - does your matching route contain a token called '{0}'?";
}
if (!string.Equals((string) expectedValue, (string) actualValue, StringComparison.InvariantCultureIgnoreCase))
throw new RouteTestingException(string.Format(errorMsgFmt, controllerParameterName, expectedValue, actualValue));
}
}
private static void CheckComplexTypeMap(RouteData routeData, object expectedValue)
{
//if its a reference try and extract its public properties and match against route data
//Don't match FormCollections
if (!(expectedValue is FormCollection))
{
var props = expectedValue
.GetType()
.GetProperties()
.Select(x => new {x.Name, Value = x.GetValue(expectedValue, null), IsEnum = (x.PropertyType.IsEnum), Type = x.PropertyType})
.ToList();
if (props.Any())
{
foreach (var prop in props)
{
if (prop.Value != null)
{
var value = routeData.Values.GetValue(prop.Name);
if (value != null)
{
if (prop.IsEnum)
{
//Check both value pass to enum then compare
if (Enum.Parse(prop.Type, prop.Value.ToString()).ToString() != Enum.Parse(prop.Type, value.ToString()).ToString())
{
throw new RouteTestingException("Failed to match route parameters to controller action arguments. " +
"Tried to match property: {0} on type: {1} which had a value of {2} and the route parameter {3} which had a value of {4} ", prop.Name, prop.Type, prop.Value, prop.Name, value);
}
}
else if (prop.Value.ToString() != value.ToString())
{
throw new RouteTestingException("Failed to match route parameters to controller action arguments. " +
"Tried to match property: {0} on type: {1} which had a value of {2} and the route parameter {3} which had a value of {4} ", prop.Name, prop.Type, prop.Value, prop.Name, value);
}
}
}
}
}
}
}
#endregion
#region WEB API
/// <summary>
/// Returns the corresponding http route for the URL. Returns null if no route was found.
/// </summary>
/// <param name="url">The app relative url to test.</param>
/// <param name="config"></param>
/// <returns>A matching <see cref="RouteData" />, or null.</returns>
public static HttpRouteTester HttpRoute(this string url, HttpConfiguration config)
{
return HttpRoute(url, config, HttpMethod.Get);
}
/// <summary>
/// Returns the corresponding http route for the URL. Returns null if no route was found.
/// </summary>
/// <param name="url">The app relative url to test.</param>
/// <param name="config"></param>
/// <param name="httpMethod">HttpMethod</param>
/// <returns>A matching <see cref="RouteData" />, or null.</returns>
public static HttpRouteTester HttpRoute(this string url, HttpConfiguration config, HttpMethod httpMethod)
{
var httpRequestMessage = new HttpRequestMessage(httpMethod, url);
return new HttpRouteTester(config, httpRequestMessage);
}
/// <summary>
/// Converts the URL to matching web api route and verifies that it will match a route with the values specified by the
/// expression.
/// </summary>
/// <typeparam name="TController">The type of web api controller</typeparam>
/// <param name="url">The ~/ based url</param>
/// <param name="config"></param>
/// <param name="action">The expression that defines what action gets called (and with which parameters)</param>
public static HttpRouteTester ShouldMapToApi<TController>(this string url, HttpConfiguration config, Expression<Func<TController, HttpResponseMessage>> action) where TController : ApiController
{
url = ConvertRelativeUrlToAbsolute(url);
return url.HttpRoute(config).ShouldMapToApi(action);
}
private static string ConvertRelativeUrlToAbsolute(string url)
{
return url.StartsWith("~") ? url.Replace("~", "http://www.site.com") : url;
}
/// <summary>
/// Verifies the <see cref="RouteData">routeData</see> maps to the controller type specified.
/// </summary>
/// <typeparam name="TController"></typeparam>
/// <param name="routeTester"></param>
/// <returns></returns>
public static HttpRouteTester ShouldMapToApi<TController>(this HttpRouteTester routeTester) where TController : ApiController
{
var expecting = typeof (TController);
if (expecting != routeTester.GetControllerType())
throw new RouteTestingException(string.Format("Controller types do not match expecting:{0} actual:{1}", expecting, routeTester.GetControllerType()));
return routeTester;
}
/// <summary>
/// Asserts that the route matches the expression specified. Checks controller, action, and any method arguments
/// into the action as route values.
/// </summary>
/// <typeparam name="TController">The controller.</typeparam>
/// <param name="routeTester">The routeData to check</param>
/// <param name="action">The action to call on TController.</param>
public static HttpRouteTester ShouldMapToApi<TController>(this HttpRouteTester routeTester, Expression<Func<TController, HttpResponseMessage>> action)
where TController : ApiController
{
if (routeTester == null)
throw new ArgumentException("The URL did not match any route");
routeTester.ShouldMapToApi<TController>();
CheckActionMaps(routeTester, action);
return routeTester;
}
private static void CheckActionMaps<TController>(HttpRouteTester routeTester, Expression<Func<TController, HttpResponseMessage>> action) where TController : ApiController
{
var methodCall = (MethodCallExpression) action.Body;
var actualAction = routeTester.GetActionName();
var expectedAction = methodCall.Method.ActionName();
if (expectedAction != actualAction)
throw new RouteTestingException(string.Format("Action did not match expecting:{0} actual:{1}", expectedAction, actualAction));
}
/// <summary>
/// A way to start the fluent interface and and which method to use
/// since you have a method constraint in the route.
/// </summary>
/// <param name="url"></param>
/// <param name="config"></param>
/// <param name="httpMethod"></param>
/// <returns></returns>
public static HttpRouteTester WithMethod(this string url, HttpConfiguration config, HttpMethod httpMethod)
{
return HttpRoute(url, config, httpMethod);
}
#endregion
}
public class RouteTestingException : Exception
{
public RouteTestingException(string message)
: base(message)
{
}
public RouteTestingException(string format, params object[] args)
: base(string.Format(format, args))
{
}
}
public class HttpRouteTester
{
private readonly HttpControllerContext _controllerContext;
private readonly IHttpControllerSelector _controllerSelector;
private readonly HttpRequestMessage _request;
public HttpRouteTester(HttpConfiguration config, HttpRequestMessage request)
{
if (config.Routes.Count == 0)
throw new ArgumentException("No routes found in route table make sure you register routes before testing route matching");
_request = request;
RouteData = config.Routes.GetRouteData(request);
if (RouteData == null)
throw new ArgumentException("No route data found for this route");
request.Properties[HttpPropertyKeys.HttpRouteDataKey] = RouteData;
_controllerSelector = new DefaultHttpControllerSelector(config);
_controllerContext = new HttpControllerContext(config, RouteData, request);
}
public IHttpRouteData RouteData { get; private set; }
public string GetActionName()
{
if (_controllerContext.ControllerDescriptor == null)
GetControllerType();
var actionSelector = new ApiControllerActionSelector();
var descriptor = actionSelector.SelectAction(_controllerContext);
return descriptor.ActionName;
}
public Type GetControllerType()
{
var descriptor = _controllerSelector.SelectController(_request);
_controllerContext.ControllerDescriptor = descriptor;
return descriptor.ControllerType;
}
}