I've configured my ASP.NET MVC5 application to use AttributeRouting for WebApi:
public static class WebApiConfig { public static void Register(HttpConfiguration config) { config.MapHttpAttributeRoutes(); } }
I have an ApiController
as follows:
[RoutePrefix("api/v1/subjects")] public class SubjectsController : ApiController { [Route("search")] [HttpPost] public SearchResultsViewModel Search(SearchCriteriaViewModel criteria) { //... } }
I would like to generate a URL to my WebApi controller action without having to specify an explicit route name.
According to this page on CodePlex, all MVC routes have a distinct name, even if it is not specified.
In the absence of a specified route name, Web API will generate a default route name. If there is only one attribute route for the action name on a particular controller, the route name will take the form "ControllerName.ActionName". If there are multiple attributes with the same action name on that controller, a suffix gets added to differentiate between the routes: "Customer.Get1", "Customer.Get2".
On ASP.NET, it doesn't say exactly what is the default naming convention, but it does indicate that every route has a name.
In Web API, every route has a name. Route names are useful for generating links, so that you can include a link in an HTTP response.
Based on these resources, and an answer by StackOverflow user Karhgath, I was led to believe that the following would produce a URL to my WebApi route:
@(Url.RouteUrl("Subjects.Search"))
However, this produces an error:
A route named 'Subjects.Search' could not be found in the route collection.
I've tried a few other variants based on other answers I found on StackOverflow, none with success.
@(Url.Action("Search", "Subjects", new { httproute = "" })) @(Url.HttpRouteUrl("Search.Subjects", new {}))
In fact, even providing a Route name in the attribute only seems to work with:
@(Url.HttpRouteUrl("Search.Subjects", new {}))
Where "Search.Subjects" is specified as the route name in the Route attribute.
I don't want to be forced to specify a unique name for my routes.
How can I generate a URL to my WebApi controller action without having to explicitly specify a route name in the Route attribute?
Is it possible that the default route naming scheme has changed or is documented incorrectly at CodePlex?
Does anyone have some insight on the proper way to retrieve a URL for a route that has been setup with AttributeRouting?
ASP.NET Web API provides an IUrlHelper interface and the corresponding UrlHelper class as a general, built-in mechanism you can use to generate links to your Web API routes. In fact, it's similar in ASP.NET MVC, so this pattern has been familiar to most devs for a while.
To enable attribute routing, call MapHttpAttributeRoutes during configuration. This extension method is defined in the System. Web. Http.
Web API 2 supports a new type of routing, called attribute routing. As the name implies, attribute routing uses attributes to define routes. Attribute routing gives you more control over the URIs in your web API.
Using a work around to find the route via inspection of Web Api's IApiExplorer
along with strongly typed expressions I was able to generate a WebApi2 URL without specifying a Name
on the Route
attribute with attribute routing.
I've created a helper extension which allows me to have strongly typed expressions with UrlHelper
in MVC razor. This works very well for resolving URIs for my MVC Controllers from with in views.
<a href="@(Url.Action<HomeController>(c=>c.Index()))">Home</a> <li>@(Html.ActionLink<AccountController>("Sign in", c => c.Signin(null)))</li> <li>@(Html.ActionLink<AccountController>("Create an account", c => c.Signup(), htmlAttributes: null))</li> @using (Html.BeginForm<ToolsController>(c => c.Track(null), FormMethod.Get, htmlAttributes: new { @class = "navbar-form", role = "search" })) {...}
I now have a view where I am trying to use knockout to post some data to my web api and need to be able to do something like this
var targetUrl = '@(Url.HttpRouteUrl<TestsApiController>(c => c.TestAction(null)))';
so that I don't have to hard code my urls (Magic strings)
My current implementation of my extension method for getting the web API url is defined in the following class.
public static class GenericUrlActionHelper { /// <summary> /// Generates a fully qualified URL to an action method /// </summary> public static string Action<TController>(this UrlHelper urlHelper, Expression<Action<TController>> action) where TController : Controller { RouteValueDictionary rvd = InternalExpressionHelper.GetRouteValues(action); return urlHelper.Action(null, null, rvd); } public const string HttpAttributeRouteWebApiKey = "__RouteName"; public static string HttpRouteUrl<TController>(this UrlHelper urlHelper, Expression<Action<TController>> expression) where TController : System.Web.Http.Controllers.IHttpController { var routeValues = expression.GetRouteValues(); var httpRouteKey = System.Web.Http.Routing.HttpRoute.HttpRouteKey; if (!routeValues.ContainsKey(httpRouteKey)) { routeValues.Add(httpRouteKey, true); } var url = string.Empty; if (routeValues.ContainsKey(HttpAttributeRouteWebApiKey)) { var routeName = routeValues[HttpAttributeRouteWebApiKey] as string; routeValues.Remove(HttpAttributeRouteWebApiKey); routeValues.Remove("controller"); routeValues.Remove("action"); url = urlHelper.HttpRouteUrl(routeName, routeValues); } else { var path = resolvePath<TController>(routeValues, expression); var root = getRootPath(urlHelper); url = root + path; } return url; } private static string resolvePath<TController>(RouteValueDictionary routeValues, Expression<Action<TController>> expression) where TController : Http.Controllers.IHttpController { var controllerName = routeValues["controller"] as string; var actionName = routeValues["action"] as string; routeValues.Remove("controller"); routeValues.Remove("action"); var method = expression.AsMethodCallExpression().Method; var configuration = System.Web.Http.GlobalConfiguration.Configuration; var apiDescription = configuration.Services.GetApiExplorer().ApiDescriptions .FirstOrDefault(c => c.ActionDescriptor.ControllerDescriptor.ControllerType == typeof(TController) && c.ActionDescriptor.ControllerDescriptor.ControllerType.GetMethod(actionName) == method && c.ActionDescriptor.ActionName == actionName ); var route = apiDescription.Route; var routeData = new HttpRouteData(route, new HttpRouteValueDictionary(routeValues)); var request = new System.Net.Http.HttpRequestMessage(); request.Properties[System.Web.Http.Hosting.HttpPropertyKeys.HttpConfigurationKey] = configuration; request.Properties[System.Web.Http.Hosting.HttpPropertyKeys.HttpRouteDataKey] = routeData; var virtualPathData = route.GetVirtualPath(request, routeValues); var path = virtualPathData.VirtualPath; return path; } private static string getRootPath(UrlHelper urlHelper) { var request = urlHelper.RequestContext.HttpContext.Request; var scheme = request.Url.Scheme; var server = request.Headers["Host"] ?? string.Format("{0}:{1}", request.Url.Host, request.Url.Port); var host = string.Format("{0}://{1}", scheme, server); var root = host + ToAbsolute("~"); return root; } static string ToAbsolute(string virtualPath) { return VirtualPathUtility.ToAbsolute(virtualPath); } }
InternalExpressionHelper.GetRouteValues
inspects the expression and generates a RouteValueDictionary
that will be used to generate the url.
static class InternalExpressionHelper { /// <summary> /// Extract route values from strongly typed expression /// </summary> public static RouteValueDictionary GetRouteValues<TController>( this Expression<Action<TController>> expression, RouteValueDictionary routeValues = null) { if (expression == null) { throw new ArgumentNullException("expression"); } routeValues = routeValues ?? new RouteValueDictionary(); var controllerType = ensureController<TController>(); routeValues["controller"] = ensureControllerName(controllerType); ; var methodCallExpression = AsMethodCallExpression<TController>(expression); routeValues["action"] = methodCallExpression.Method.Name; //Add parameter values from expression to dictionary var parameters = buildParameterValuesFromExpression(methodCallExpression); if (parameters != null) { foreach (KeyValuePair<string, object> parameter in parameters) { routeValues.Add(parameter.Key, parameter.Value); } } //Try to extract route attribute name if present on an api controller. if (typeof(System.Web.Http.Controllers.IHttpController).IsAssignableFrom(controllerType)) { var routeAttribute = methodCallExpression.Method.GetCustomAttribute<System.Web.Http.RouteAttribute>(false); if (routeAttribute != null && routeAttribute.Name != null) { routeValues[GenericUrlActionHelper.HttpAttributeRouteWebApiKey] = routeAttribute.Name; } } return routeValues; } private static string ensureControllerName(Type controllerType) { var controllerName = controllerType.Name; if (!controllerName.EndsWith("Controller", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("Action target must end in controller", "action"); } controllerName = controllerName.Remove(controllerName.Length - 10, 10); if (controllerName.Length == 0) { throw new ArgumentException("Action cannot route to controller", "action"); } return controllerName; } internal static MethodCallExpression AsMethodCallExpression<TController>(this Expression<Action<TController>> expression) { var methodCallExpression = expression.Body as MethodCallExpression; if (methodCallExpression == null) throw new InvalidOperationException("Expression must be a method call."); if (methodCallExpression.Object != expression.Parameters[0]) throw new InvalidOperationException("Method call must target lambda argument."); return methodCallExpression; } private static Type ensureController<TController>() { var controllerType = typeof(TController); bool isController = controllerType != null && controllerType.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) && !controllerType.IsAbstract && ( typeof(IController).IsAssignableFrom(controllerType) || typeof(System.Web.Http.Controllers.IHttpController).IsAssignableFrom(controllerType) ); if (!isController) { throw new InvalidOperationException("Action target is an invalid controller."); } return controllerType; } private static RouteValueDictionary buildParameterValuesFromExpression(MethodCallExpression methodCallExpression) { RouteValueDictionary result = new RouteValueDictionary(); ParameterInfo[] parameters = methodCallExpression.Method.GetParameters(); if (parameters.Length > 0) { for (int i = 0; i < parameters.Length; i++) { object value; var expressionArgument = methodCallExpression.Arguments[i]; if (expressionArgument.NodeType == ExpressionType.Constant) { // If argument is a constant expression, just get the value value = (expressionArgument as ConstantExpression).Value; } else { try { // Otherwise, convert the argument subexpression to type object, // make a lambda out of it, compile it, and invoke it to get the value var convertExpression = Expression.Convert(expressionArgument, typeof(object)); value = Expression.Lambda<Func<object>>(convertExpression).Compile().Invoke(); } catch { // ????? value = String.Empty; } } result.Add(parameters[i].Name, value); } } return result; } }
The trick was to get the route to the action and use that to generate the URL.
private static string resolvePath<TController>(RouteValueDictionary routeValues, Expression<Action<TController>> expression) where TController : Http.Controllers.IHttpController { var controllerName = routeValues["controller"] as string; var actionName = routeValues["action"] as string; routeValues.Remove("controller"); routeValues.Remove("action"); var method = expression.AsMethodCallExpression().Method; var configuration = System.Web.Http.GlobalConfiguration.Configuration; var apiDescription = configuration.Services.GetApiExplorer().ApiDescriptions .FirstOrDefault(c => c.ActionDescriptor.ControllerDescriptor.ControllerType == typeof(TController) && c.ActionDescriptor.ControllerDescriptor.ControllerType.GetMethod(actionName) == method && c.ActionDescriptor.ActionName == actionName ); var route = apiDescription.Route; var routeData = new HttpRouteData(route, new HttpRouteValueDictionary(routeValues)); var request = new System.Net.Http.HttpRequestMessage(); request.Properties[System.Web.Http.Hosting.HttpPropertyKeys.HttpConfigurationKey] = configuration; request.Properties[System.Web.Http.Hosting.HttpPropertyKeys.HttpRouteDataKey] = routeData; var virtualPathData = route.GetVirtualPath(request, routeValues); var path = virtualPathData.VirtualPath; return path; }
So now if for example I have the following api controller
[RoutePrefix("api/tests")] [AllowAnonymous] public class TestsApiController : WebApiControllerBase { [HttpGet] [Route("{lat:double:range(-90,90)}/{lng:double:range(-180,180)}")] public object Get(double lat, double lng) { return new { lat = lat, lng = lng }; } }
Works for the most part so far when I test it
@section Scripts { <script type="text/javascript"> var url = '@(Url.HttpRouteUrl<TestsApiController>(c => c.Get(1,2)))'; alert(url); </script> }
I get /api/tests/1/2
, which is what I wanted and what I believe would satisfy your requirements.
Note that it will also default back to the UrlHelper for actions with route attributes that have the Name
.
According to this page on CodePlex, all MVC routes have a distinct name, even if it is not specified.
Docs on codeplex is for WebApi 2.0 beta and looks like things have changed since that.
I have debugded attribute routes and it looks like WebApi create single route for all actions without specified RouteName
with the name MS_attributerouteWebApi
.
You can find it in _routeCollection._namedMap
field:
GlobalConfiguration.Configuration.Routes)._routeCollection._namedMap
This collection is also populated with named routes for which route name was specified explicitly via attribute.
When you generate URL with Url.Route("RouteName", null);
it searches for route names in _routeCollection
field:
VirtualPathData virtualPath1 = this._routeCollection.GetVirtualPath(requestContext, name, values1);
And it will find only routes specified with route attributes there. Or with config.Routes.MapHttpRoute
of course.
I don't want to be forced to specify a unique name for my routes.
Unfortunately, there is no way to generate URL for WebApi action without specifying route name explicitly.
In fact, even providing a Route name in the attribute only seems to work with
Url.HttpRouteUrl
Yes, and that is because API routes and MVC routes use different collections to store routes and have different internal implementation.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With