20 de noviembre de 2017

Redirección fuertemente tipada en ASP.NET MVC

Recientemente volví a caer en un error de los que considero de primer año: cambié la firma de un método de acción en un controlador de MVC y olvidé buscar el 100% de todos los lugares que redirigían hacia él. Por supuesto, esto causó un error --que afortunadamente fue encontrado en QA-- ya que el código en cuestión (claro está) no tenía pruebas de ninguna clase.

El código en cuestión era similar al siguiente:

public ActionResult Foo()
{
  ...
  return RedirectToAction(
      "SomeAction", "SomePlace",
      new { usr = "Username", key = blah });
}

El problema es que fue necesario agregar un parámetro al método SomePlaceController.SomeAction, pero absolutamente nada en el código me advirtió que había nada más que cambiar además de la función de JavaScript que estaba modificando en ese momento. Fue la gota que derramó el vaso: desde que he estado dando mantenimiento a código legacy en ASP.NET MVC, no sé cuantas veces me ha sucedido este mismo problema y ya era hora de hacer algo al respecto, así que decidí que ya que estoy trabajando con C#, tal vez podría utilizar su sistema de tipos a mi favor. Lo que me gustaría poder hacer sería algo así:

public ActionResult Foo()
{
  ...
  return RedirectToAction<SomePlaceController>(
    x => x.SomeAction("Username", blah, 125));
}

Por supuesto, no existe una definición de RedirectToAction como esta, pero es relativamente fácil de definir usando expresiones de linq:
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Web.Mvc;
using System.Web.Routing;

using RouteValue = System.Tuple<string, object>;

public static class RedirectTo
{
  #region Public Methods

  private const string InvalidTarget =
    "El argumento debe ser una invocación a un método del controlador.";

  private const string UnsupportedExpression = "Expresion no soportada";

  #endregion

  #region Public Methods

  public static RedirectToRouteResult RedirectToAction<T>(
    this T controller, Expression<Action<T>> selector)
    where T : Controller => Action(selector);

  public static RedirectToRouteResult Action<T>(
    Expression<Action<T>> selector) where T : Controller
  {
    if (!(selector.Body is MethodCallExpression))
    {
      throw new ArgumentException(InvalidTarget, nameof(selector));
    }

    var methodEx = (MethodCallExpression)selector.Body;

    var routeValues = MakeRouteValues(
      controllerName: GetControllerName<T>(),
      actionName: GetActionName(methodEx),
      arguments: GetArguments(methodEx));

    return new RedirectToRouteResult(routeValues);
  }

  #endregion

  #region Methods

  private static string GetControllerName<T>() where T : Controller
  {
    const string Suffix = "Controller";

    var name = typeof(T).Name;

    return name.EndsWith(Suffix)
      ? name.Substring(0, name.Length - Suffix.Length)
      : name;
  }

  private static string GetActionName(MethodCallExpression expression)
    => expression.Method.Name;

  private static RouteValue[] GetArguments(MethodCallExpression expression)
  {
    var names = expression.Method.GetParameters().Select(o => o.Name);
    var values = expression.Arguments.Select(GetValueOf);

    return names
      .Zip(values, (n, v) => new RouteValue(n, v))
      .Where(o => o.Item2 != null)
      .ToArray();
  }

  private static object GetValueOf(Expression expression)
  {
    if (expression is MemberExpression)
    {
      return GetValueOf((MemberExpression)expression);
    }
    else if (expression is ConstantExpression)
    {
      return GetValueOf((ConstantExpression)expression);
    }

    throw new InvalidOperationException(UnsupportedExpression);
  }

  private static object GetValueOf(MemberExpression expression)
  {
    var container = GetValueOf(expression.Expression);

    if (expression.Member is FieldInfo)
    {
      return ((FieldInfo)expression.Member).GetValue(container);
    }
    else if (expression.Member is PropertyInfo)
    {
      return ((PropertyInfo)expression.Member).GetValue(container);
    }

    throw new InvalidOperationException(UnsupportedExpression);
  }

  private static object GetValueOf(ConstantExpression expression)
    => expression.Value;

  private static RouteValueDictionary MakeRouteValues(
    string actionName, string controllerName, params RouteValue[] arguments)
  {
    var args = new RouteValue[]
      {
        new RouteValue("action", actionName),
        new RouteValue("controller", controllerName),
      };

    return new RouteValueDictionary(args
      .Concat(arguments)
      .ToDictionary(o => o.Item1, o => o.Item2));
  }

  #endregion
}

Con esto, el sintaxis que obtenemos es muy similar al que queríamos:
public ActionResult Foo()
{
  ...
  return RedirectTo.Action<SomePlaceController>(
    x => x.SomeAction("Username", blah, 125));
}

o incluso
public ActionResult SomeAction(
  string username, Guid key, long token)
{
  var credentials = GetCredentials(username, token);
  ...
  return this.RedirectToAction(x => x.Continue(username, credentials));
}

Con esto, me aseguraré de que la próxima vez que necesite agregar un parámetro al un método de un controlador, no pueda hacerlo sin tener por lo menos que revisar cualquier redirección que haya hacia el mismo (por lo menos en código compilado del lado del servidor).