How to use C# nameof() with ASP.NET MVC Url.Action
Asked Answered
C

9

50

Is there a recommended way to use the new

nameof()

expression in ASP.NET MVC for controller names?

Url.Action("ActionName", "Home")  <------ works

vs

Url.Action(nameof(ActionName), nameof(HomeController)) <----- doesn't work

obviously it doesn't work because of nameof(HomeController) converts to "HomeController" and what MVC needs is just "Home".

Chavey answered 12/12, 2014 at 12:54 Comment(5)
I suppose you could make an extensionmethod on he 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.Hydrostatics
Seems like T4MVC would better suited for thisOtalgia
Used T4 before. Now with nameof as a language feature I guess it's better to stay inside c# whenever possible.Chavey
Any reason you can just operate on the resulting string that comes from nameof() and chop off the Controller suffix?Statesman
For ASP.NET MVC 6 users see this issue in GitHub: github.com/aspnet/Mvc/issues/2608 Using nameof(SomeController) is problematic because asp-controller tag helper expects the name of the controller without the Controller sufix.Subdelirium
A
27

I like James' suggestion of using an extension method. There is just one problem: although you're using nameof() and have eliminated magic strings, there's still a small issue of type safety: you're still working with strings. As such, it is very easy to forget to use the extension method, or to provide an arbitrary string that isn't valid (e.g. mistyping the name of a controller).

I think we can improve James' suggestion by using a generic extension method for Controller, where the generic parameter is the target controller:

public static class ControllerExtensions
{
    public static string Action<T>(this Controller controller, string actionName)
        where T : Controller
    {
        var name = typeof(T).Name;
        string controllerName = name.EndsWith("Controller")
            ? name.Substring(0, name.Length - 10) : name;
        return controller.Url.Action(actionName, controllerName);
    }
}

The usage is now much cleaner:

this.Action<HomeController>(nameof(ActionName));
Aneroidograph answered 14/12, 2014 at 0:30 Comment(0)
B
8

Consider an extension method:

public static string UrlName(this Type controller)
{
  var name = controller.Name;
  return name.EndsWith("Controller") ? name.Substring(0, name.Length - 10) : name;
}

Then you can use:

Url.Action(nameof(ActionName), typeof(HomeController).UrlName())
Bookbindery answered 12/12, 2014 at 17:6 Comment(2)
passing Type is risky.Slinky
For that reason I would throw an exception if the name does not end with "Controller".Reincarnate
A
6

I need to make sure routeValues are processed properly, and not always treated like querystring values. But, I still want to make sure the actions match the controllers.

My solution is to create extension overloads for Url.Action.

<a href="@(Url.Action<MyController>(x=>x.MyAction))">Button Text</a>

I have overloads for single parameter actions for different types. If I need to pass routeValues...

<a href="@(Url.Action<MyController>(x=>x.MyAction, new { myRouteValue = myValue }))">Button Text</a>

For actions with complicated parameters that I haven't explicitly created overloads for, the types need to be specified with the controller type to match the action definition.

<a href="@(Url.Action<MyController,int,string>(x=>x.MyAction, new { myRouteValue1 = MyInt, MyRouteValue2 = MyString}))">Button Text</a>

Of course, most of the time the action stays within the same controller, so I still just use nameof for those.

<a href="@Url.Action(nameof(MyController.MyAction))">Button Text</a>

Since routeValues don't necessarily match the action parameters, this solution allows for that flexibility.

Extension Code

namespace System.Web.Mvc {
    public static class UrlExtensions {

    // Usage : <a href="@(Url.Action<MyController>(x=>x.MyActionNoVars, new {myroutevalue = 1}))"></a>
    public static string Action<T>(this UrlHelper helper,Expression<Func<T,Func<ActionResult>>> expression,object routeValues = null) where T : Controller
        => helper.Action<T>((LambdaExpression)expression,routeValues);

    // Usage : <a href="@(Url.Action<MyController,vartype1>(x=>x.MyActionWithOneVar, new {myroutevalue = 1}))"></a>
    public static string Action<T, P1>(this UrlHelper helper,Expression<Func<T,Func<P1,ActionResult>>> expression,object routeValues = null) where T : Controller
        => helper.Action<T>(expression,routeValues);

    // Usage : <a href="@(Url.Action<MyController,vartype1,vartype2>(x=>x.MyActionWithTwoVars, new {myroutevalue = 1}))"></a>
    public static string Action<T, P1, P2>(this UrlHelper helper,Expression<Func<T,Func<P1,P2,ActionResult>>> expression,object routeValues = null) where T : Controller
        => helper.Action<T>(expression,routeValues);

    // Usage : <a href="@(Url.Action<MyController>(x=>x.MyActionWithOneInt, new {myroutevalue = 1}))"></a>
    public static string Action<T>(this UrlHelper helper,Expression<Func<T,Func<int,ActionResult>>> expression,object routeValues = null) where T : Controller
        => helper.Action<T>((LambdaExpression)expression,routeValues);

    // Usage : <a href="@(Url.Action<MyController>(x=>x.MyActionWithOneString, new {myroutevalue = 1}))"></a>
    public static string Action<T>(this UrlHelper helper,Expression<Func<T,Func<string,ActionResult>>> expression,object routeValues = null) where T : Controller
        => helper.Action<T>((LambdaExpression)expression,routeValues);

    //Support function
    private static string Action<T>(this UrlHelper helper,LambdaExpression expression,object routeValues = null) where T : Controller
        => helper.Action(
                ((MethodInfo)((ConstantExpression)((MethodCallExpression)((UnaryExpression)expression.Body).Operand).Object).Value).Name,
                typeof(T).Name.Replace("Controller","").Replace("controller",""),
                routeValues);
    }
}
Atwell answered 3/11, 2017 at 16:14 Comment(0)
E
3

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"
});
Ellersick answered 18/10, 2017 at 13:7 Comment(5)
Have you seen T4MVC? Solves all your problemsSubarid
@trailmax, yes, it does have a lot of features but sometimes you don't need that much.Ellersick
This is a great solution. My only problem is that most of the time my routeValues don't match the count and types of my action parameters. Date types are a good example, as I generally want to format them as dash strings before putting them in a URL. I appreciate the work as it contributed to my solution.Atwell
@CarterMedlin, in case of a model binding where the model is not of a primitive type, you could declare an interface to be implemented by that model and then used in the ActionHelper.GetParameter() to get the necessary routeValues. For example, it could be NameValueCollection IUrlSerializable.GetQueryParams(). If a model does not support the interface, it could fall back to ToString() like currently.Ellersick
@CarterMedlin, I updated my answer - see the code above.Ellersick
G
1

Building off of Gigi's answer (which introduced type-safety for controllers), I went an extra step. I very much like T4MVC, but I never liked having to run the T4 generation. I like code generation, but it's not native to MSBuild, so build servers have a hard time with it.

I re-used the generic concept and added in an Expression parameter:

public static class ControllerExtensions
{
    public static ActionResult RedirectToAction<TController>(
        this Controller controller, 
        Expression<Func<TController, ActionResult>> expression)
        where TController : Controller
    {
        var fullControllerName = typeof(TController).Name;
        var controllerName = fullControllerName.EndsWith("Controller")
            ? fullControllerName.Substring(0, fullControllerName.Length - 10)
            : fullControllerName;

        var actionCall = (MethodCallExpression) expression.Body;
        return controller.RedirectToAction(actionCall.Method.Name, controllerName);
    }
}

An example call for the above would look like:

    public virtual ActionResult Index()
    {
        return this.RedirectToAction<JobController>( controller => controller.Index() );
    }

If JobController didn't have Index, you'd run into a compiler error. That's probably the only advantage this has over the previous answer - so it's another stupidity check. It'd help you stop using JobController if JobController didn't have Index. Also, it'll give you intellisense when looking for the action.

--

I also added in this signature:

    public static ActionResult RedirectToAction<TController>(this TController controller, Expression<Func<TController, ActionResult>> expression)
        where TController : Controller

This allows a simpler way of typing in actions for the current controller, without needing to specify the type. The two can be used side-by-side:

    public virtual ActionResult Index()
    {
        return this.RedirectToAction(controller => controller.Test());
    }
    public virtual ActionResult Test()
    {
         ...
    }

--

I was asked in a comment if this supported parameters. The answer for the above is no. However, I hacked away real fast to create a version that could parse the parameters. This is the adjusted method:

    public static ActionResult RedirectToAction<TController>(this Controller controller, Expression<Func<TController, ActionResult>> expression)
        where TController : Controller
    {
        var fullControllerName = typeof(TController).Name;
        var controllerName = fullControllerName.EndsWith("Controller")
            ? fullControllerName.Substring(0, fullControllerName.Length - 10)
            : fullControllerName;

        var actionCall = (MethodCallExpression)expression.Body;

        var routeValues = new ExpandoObject();
        var routeValuesDictionary = (IDictionary<String, Object>)routeValues;
        var parameters = actionCall.Method.GetParameters();
        for (var i = 0; i < parameters.Length; i++)
        {
            var arugmentLambda = Expression.Lambda(actionCall.Arguments[i], expression.Parameters);
            var arugmentDelegate = arugmentLambda.Compile();
            var argumentValue = arugmentDelegate.DynamicInvoke(controller);
            routeValuesDictionary[parameters[i].Name] = argumentValue;
        }
        return controller.RedirectToAction(actionCall.Method.Name, controllerName, routeValues);
    }

I haven't personally tested it (but Intellisense makes it appear that it would compile). To sum up, the code looks at all the parameters for the method, and creates an ExpandoObject that contains all of the parameters. The values are determined from the passed in expression, by calling each as an independent lambda expression by using the original parameters of the master expression. You then compile and invoke the expression, and store the resulting value in the ExpandoObject. The results are then passed into the built-in helpers.

Girondist answered 2/10, 2019 at 17:31 Comment(4)
But this does not support actions with parameters, right? So if you had controller.Index("somestring"), you would lose the method parameter.Expressway
@JanWichelmann: You are correct. I've added an edit to my answer to contain an untested concept for passing parameters from from the expression to the underlying call. Since this actually calls each argument, this would allow for even complicated expressions to work with this. I'm sure performance for it wouldn't be great, however.Girondist
Thanks for updating! I was spending the last hour trying to implement exactly this, but failed when trying to use more complicated arguments like property accessors or function calls. Calling .Compile() appears to be the right way to work around this.Expressway
That's actually why I avoided the topic on my original answer. Expressions can suck to work with. I'm glad it worked for you!Girondist
F
1

A simple constant does the job or, if you prefer, an interface:

internal interface IBaseController { public string Name { get; } }
public class MyController : IBaseController
{
    public const string NAME = "My";
    // or
    public string Name { get => "My"; }
    ...
}

No calculations needed:

<a asp-controller="@MyController.Name" asp-action="@nameof(MyController.Index)">Close</a>
Folkway answered 18/11, 2022 at 11:34 Comment(0)
P
0

A take on @James answer:

Instead, using a string extension method: Returns the controller names prefix otherwise the parameter passed in.

    /// <summary>
    /// Gets the prefix of the controller name.
    /// <para> <see langword="Usage:"/>
    /// <code>var <paramref name="controllerNamePrefix"/> = 
    /// <see langword="nameof"/>(ExampleController).
    /// <see cref="GetControllerPrefix()"/>;
    /// </code>
    /// </para>
    /// </summary>
    /// <param name="fullControllerName"></param>
    /// <returns></returns>
    public static string GetControllerPrefix(this string fullControllerName)
    {
        const string Controller = nameof(Controller);

        if (string.IsNullOrEmpty(fullControllerName) || !fullControllerName.EndsWith(Controller))
            return fullControllerName;

        return fullControllerName.Substring(0, fullControllerName.Length - Controller.Length);
    }
Posthorse answered 25/3, 2020 at 2:42 Comment(0)
C
0

For those looking how to do this in ASP.NET Core, try this: https://github.com/ivaylokenov/AspNet.Mvc.TypedRouting

@(Html.ActionLink<HomeController>("Home page", c => c.Index()))
Coparcener answered 5/3, 2021 at 23:32 Comment(0)
R
0

I use something like this and it works: Url.Action(nameof(ActionName), nameof(HomeController).Replace("Controller", string.Empty))

Rivulet answered 7/7, 2021 at 11:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.