All the solutions I have seen so far have one drawback: while they make changing controller's or action's name safe, they do not guarantee consistency between those two entities. You may specify an action from a different controller:
public class HomeController : Controller
{
public ActionResult HomeAction() { ... }
}
public class AnotherController : Controller
{
public ActionResult AnotherAction() { ... }
private void Process()
{
Url.Action(nameof(AnotherAction), nameof(HomeController));
}
}
To make it even worse, this approach cannot take into account the numerous attributes one may apply to controllers and/or actions to change routing, e.g. RouteAttribute
and RoutePrefixAttribute
, so any change to the attribute-based routing may go unnoticed.
Finally, the Url.Action()
itself does not ensure consistency between action method and its parameters that constitute the URL:
public class HomeController : Controller
{
public ActionResult HomeAction(int id, string name) { ... }
private void Process()
{
Url.Action(nameof(HomeAction), new { identity = 1, title = "example" });
}
}
My solution is based on Expression
and metadata:
public static class ActionHelper<T> where T : Controller
{
public static string GetUrl(Expression<Func<T, Func<ActionResult>>> action)
{
return GetControllerName() + '/' + GetActionName(GetActionMethod(action));
}
public static string GetUrl<U>(
Expression<Func<T, Func<U, ActionResult>>> action, U param)
{
var method = GetActionMethod(action);
var parameters = method.GetParameters();
return GetControllerName() + '/' + GetActionName(method) +
'?' + GetParameter(parameters[0], param);
}
public static string GetUrl<U1, U2>(
Expression<Func<T, Func<U1, U2, ActionResult>>> action, U1 param1, U2 param2)
{
var method = GetActionMethod(action);
var parameters = method.GetParameters();
return GetControllerName() + '/' + GetActionName(method) +
'?' + GetParameter(parameters[0], param1) +
'&' + GetParameter(parameters[1], param2);
}
private static string GetControllerName()
{
const string SUFFIX = nameof(Controller);
string name = typeof(T).Name;
return name.EndsWith(SUFFIX) ? name.Substring(0, name.Length - SUFFIX.Length) : name;
}
private static MethodInfo GetActionMethod(LambdaExpression expression)
{
var unaryExpr = (UnaryExpression)expression.Body;
var methodCallExpr = (MethodCallExpression)unaryExpr.Operand;
var methodCallObject = (ConstantExpression)methodCallExpr.Object;
var method = (MethodInfo)methodCallObject.Value;
Debug.Assert(method.IsPublic);
return method;
}
private static string GetActionName(MethodInfo info)
{
return info.Name;
}
private static string GetParameter<U>(ParameterInfo info, U value)
{
return info.Name + '=' + Uri.EscapeDataString(value.ToString());
}
}
This prevents you from passing wrong parameters to generate a URL:
ActionHelper<HomeController>.GetUrl(controller => controller.HomeAction, 1, "example");
Since it is a lambda expression, action is always bound to its controller. (And you also have Intellisense!) Once the action is chosen, it forces you to specify all of its parameters of correct type.
The given code still does not address the routing issue, however fixing it is at least possible, as there are both controller's Type.Attributes
and MethodInfo.Attributes
available.
EDIT:
As @CarterMedlin pointed out, action parameters of non-primitive type may not have a one-to-one binding to query parameters. Currently, this is resolved by calling ToString()
that may be overridden in the parameter class specifically for this purpose. However the approach may not always be applicable, neither does it control the parameter name.
To resolve the issue, you can declare the following interface:
public interface IUrlSerializable
{
Dictionary<string, string> GetQueryParams();
}
and implement it in the parameter class:
public class HomeController : Controller
{
public ActionResult HomeAction(Model model) { ... }
}
public class Model : IUrlSerializable
{
public int Id { get; set; }
public string Name { get; set; }
public Dictionary<string, string> GetQueryParams()
{
return new Dictionary<string, string>
{
[nameof(Id)] = Id,
[nameof(Name)] = Name
};
}
}
And respective changes to ActionHelper
:
public static class ActionHelper<T> where T : Controller
{
...
private static string GetParameter<U>(ParameterInfo info, U value)
{
var serializableValue = value as IUrlSerializable;
if (serializableValue == null)
return GetParameter(info.Name, value.ToString());
return String.Join("&",
serializableValue.GetQueryParams().Select(param => GetParameter(param.Key, param.Value)));
}
private static string GetParameter(string name, string value)
{
return name + '=' + Uri.EscapeDataString(value);
}
}
As you can see, it still has a fallback to ToString()
, when the parameter class does not implement the interface.
Usage:
ActionHelper<HomeController>.GetUrl(controller => controller.HomeAction, new Model
{
Id = 1,
Name = "example"
});
Controller
class whhich could cut the "Controller" part away from you. But I haven't seen a specific recommendation on how to use it yet. – Hydrostaticsnameof()
and chop off theController
suffix? – Statesmannameof(SomeController)
is problematic becauseasp-controller
tag helper expects the name of the controller without theController
sufix. – Subdelirium