Strongly typed url action
Asked Answered
R

3

8

I've read multiple posts and blogs similar to

Delegate-based strongly-typed URL generation in ASP.NET MVC

But none of them really quite do what I'd like to do. Currently I have a hybrid approach like:

// shortened for Brevity
public static Exts
{  
  public string Action(this UrlHelper url, 
    Expression<Func<T, ActionResult>> expression)
    where T : ControllerBase
  {
    return Exts.Action(url, expression, null);
  }

  public string Action(this UrlHelper url, 
    Expression<Func<T, ActionResult>> expression,
    object routeValues)
    where T : ControllerBase
  {
    string controller;
    string action;

    // extension method 
    expression.GetControllerAndAction(out controller, out action);

    var result = url.Action(action, controller, routeValues);

    return result;
  }
}

Works great if you're controller methods don't have any parameters:

public class MyController : Controller 
{
  public ActionResult MyMethod()
  {
    return null;
  }
  public ActionResult MyMethod2(int id)
  {
    return null;
  }
}

Then I can:

Url.Action<MyController>(c => c.MyMethod())

But if my method takes a parameter, then I have to pass a value (that I would never use):

Url.Action<MyController>(c => c.MyMethod2(-1), new { id = 99 })

So the question is there a way to change the extension method to still require the first parameter to be a method defined on type T that does check to make sure the return parameter is an ActionResult without actually specifying a parameter, something like:

Url.Action<MyController>(c => c.MyMethod2, new { id = 99 })

So this would pass a pointer to the method (like a reflection MethodInfo) instead of the Func<>, so it wouldn't care about parameters. What would that signature look like if it was possible?

Rigby answered 17/12, 2015 at 22:44 Comment(4)
With c.MyMethod2 you're pointing to a method group, any of which can return something else... But I'm fairly sure I've seen libraries that enable this. Perhaps you can do some reflection magic and check in GetControllerAndAction that the method of the group matching the provided parameters does indeed return ActionResult. This will not exactly give you the compile-time safety you're looking for, but you shouldn't have non-actionmethods as public methods in your controller anyway.Iridis
c => c.MyMethod2 can't be cast from Method Group to non-delegate type ActionResult.Rigby
You're right of course, that'll only work in the current controller, not in the view. Why aren't you using c => c.MyMethod2(99) instead (using MethodCallExpression.Arguments to get the arguments)?Iridis
Let me think about that... I intentionally removed that code because it seems ambiguous: c => c.MyMethod(99), new { id = 98 }... but maybe I don't need route values at that point... I'm trying to think of a reason I would need a route value...Rigby
I
4

You can't do this:

c => c.MyMethod2

Because that is a method group. Any method in a method group can return void or anything else, so the compiler won't allow it:

Error CS0428  Cannot convert method group '...' to non-delegate type '...'

There may be a method in the group returning an ActionMethod, or none. You need to decide that.

But you don't have to provide a method group anyway. You can just use your existing signature, minus the object routeValues, and call it like this:

Url.Action<MyController>(c => c.MyMethod(99))

Then in your method, you can use the MethodInfo methodCallExpression.Method to obtain the method parameter names, and the methodCallExpression.Arguments to get the arguments.

Then your next problem is creating the anonymous object at runtime. Luckily you don't have to, as Url.Action() also has an overload accepting a RouteValueDictionary.

Zip the parameters and arguments together into a dictionary, create a RouteValueDictionary from that, and pass that to Url.Action():

var methodCallExpression = expression.Body as MethodCallExpression;
if (methodCallExpression == null)
{                
    throw new ArgumentException("Not a MethodCallExpression", "expression");
}

var methodParameters = methodCallExpression.Method.GetParameters();
var routeValueArguments = methodCallExpression.Arguments.Select(EvaluateExpression);

var rawRouteValueDictionary = methodParameters.Select(m => m.Name)
                            .Zip(routeValueArguments, (parameter, argument) => new
                            {
                                parameter,
                                argument
                            })
                            .ToDictionary(kvp => kvp.parameter, kvp => kvp.argument);

var routeValueDictionary = new RouteValueDictionary(rawRouteValueDictionary);

// action and controller obtained through your logic 

return url.Action(action, controller, routeValueDictionary);

The EvaluateExpression method very naively compiles and invokes every non-constant expression, so may prove to be horribly slow in practice:

private static object EvaluateExpression(Expression expression)
{
    var constExpr = expression as ConstantExpression;
    if (constExpr != null)
    {
        return constExpr.Value;
    }

    var lambda = Expression.Lambda(expression);
    var compiled = lambda.Compile();
    return compiled.DynamicInvoke();
}

However, in the Microsoft ASP.NET MVC Futures package there's the convenient ExpressionHelper.GetRouteValuesFromExpression(expr)‌​, which also handles routing and areas. Your entire method then can be replaced with:

var routeValues = Microsoft.Web.Mvc.Internal.ExpressionHelper.GetRouteValuesFromExpression<T>(expression);
return url.Action(routeValues["Action"], routeValues["Controller"], routeValues);

It uses a cached expression compiler internally, so it works for all use cases and you won't have to reinvent the wheel.

Iridis answered 17/12, 2015 at 23:23 Comment(4)
If they aren't a ConstantExpression wouldn't I have to compile and execute that expression to get a value for the RouteValueDictionary?Rigby
I can't use the Futures package, but I can just compile() the expression and use the value as object hopefully..Rigby
@Erik I've done that, see edit - and caveat. It works for me with constants, method calls and member access, but you'll really have to test it yourself. I think you'll also have to look for an Area attribute on the controller and add that to the routeValueDictionary if you want it to work with areas.Iridis
what I'm going to do is check the controller's namespace. If I get crazy I can validate it against AreaRegistration :)Rigby
R
1

As an alternative for other projects, I've recently started using nameof.

Url.Action(nameof(MyController.MyMethod), nameof(MyController), new { id = 99 })

the only real downside is that they can be mixed and produce incorrect results post compile:

Url.Action(nameof(SomeOtherController.MyMethod), nameof(MyController), new { id = 99 })

The controllers don't match but I don't think it's a big deal. It still throws and error during compiling when the controller name or the method name changes and isn't updated else where in code.

Rigby answered 20/4, 2019 at 19:12 Comment(0)
L
-1

Ivaylo Kenov created a Nuget package AspNet.Mvc.TypedRouting (Github repository)

Just call the AddTypedRouting extension method after the AddMvc one

services.AddMvc().AddTypedRouting();

Or if you're developing a web API:

services.AddControllers().AddTypedRouting();

Then you can get URLs with an expression as you'd like to

Url.Action<FooController>(c => c.Get(fooId));
Luftwaffe answered 11/9, 2020 at 12:26 Comment(3)
This is exactly what I didn't want to do; I don't want to pass the required parameters.Rigby
@ErikPhilips That's not what you said in your question. You said you wanted something like Url.Action<MyController>(c => c.MyMethod2, new { id = 99 }) where you pass the id parameter but avoid passing an unused value -1 like in Url.Action<MyController>(c => c.MyMethod2(-1), new { id = 99 }). I think that's exactly answering your answerRattle
So the question is there a way to change the extension method to still require the first parameter to be a method defined on type T that does check to make sure the return parameter is an ActionResult without actually specifying a parameter, something like:Rigby

© 2022 - 2024 — McMap. All rights reserved.