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:

  1. public ActionResult Foo()
  2. {
  3.   ...
  4.   return RedirectToAction(
  5. "SomeAction", "SomePlace",
  6. new { usr = "Username", key = blah });
  7. }

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í:

  1. public ActionResult Foo()
  2. {
  3.   ...
  4.   return RedirectToAction<SomePlaceController>(
  5.   x => x.SomeAction("Username", blah, 125));
  6. }

Por supuesto, no existe una definición de RedirectToAction como esta, pero es relativamente fácil de definir usando expresiones de linq:
  1. using System;
  2. using System.Linq;
  3. using System.Linq.Expressions;
  4. using System.Reflection;
  5. using System.Web.Mvc;
  6. using System.Web.Routing;
  7.  
  8. using RouteValue = System.Tuple<string, object>;
  9.  
  10. public static class RedirectTo
  11. {
  12. #region Public Methods
  13.  
  14. private const string InvalidTarget =
  15. "El argumento debe ser una invocación a un método del controlador.";
  16.  
  17. private const string UnsupportedExpression = "Expresion no soportada";
  18.  
  19. #endregion
  20.  
  21. #region Public Methods
  22.  
  23. public static RedirectToRouteResult RedirectToAction<T>(
  24. this T controller, Expression<Action<T>> selector)
  25. where T : Controller => Action(selector);
  26.  
  27. public static RedirectToRouteResult Action<T>(
  28. Expression<Action<T>> selector) where T : Controller
  29. {
  30. if (!(selector.Body is MethodCallExpression))
  31. {
  32. throw new ArgumentException(InvalidTarget, nameof(selector));
  33. }
  34.  
  35. var methodEx = (MethodCallExpression)selector.Body;
  36.  
  37. var routeValues = MakeRouteValues(
  38. controllerName: GetControllerName<T>(),
  39. actionName: GetActionName(methodEx),
  40. arguments: GetArguments(methodEx));
  41.  
  42. return new RedirectToRouteResult(routeValues);
  43. }
  44.  
  45. #endregion
  46.  
  47. #region Methods
  48.  
  49. private static string GetControllerName<T>() where T : Controller
  50. {
  51. const string Suffix = "Controller";
  52.  
  53. var name = typeof(T).Name;
  54.  
  55. return name.EndsWith(Suffix)
  56. ? name.Substring(0, name.Length - Suffix.Length)
  57. : name;
  58. }
  59.  
  60. private static string GetActionName(MethodCallExpression expression)
  61. => expression.Method.Name;
  62.  
  63. private static RouteValue[] GetArguments(MethodCallExpression expression)
  64. {
  65. var names = expression.Method.GetParameters().Select(o => o.Name);
  66. var values = expression.Arguments.Select(GetValueOf);
  67.  
  68. return names
  69. .Zip(values, (n, v) => new RouteValue(n, v))
  70. .Where(o => o.Item2 != null)
  71. .ToArray();
  72. }
  73.  
  74. private static object GetValueOf(Expression expression)
  75. {
  76. if (expression is MemberExpression)
  77. {
  78. return GetValueOf((MemberExpression)expression);
  79. }
  80. else if (expression is ConstantExpression)
  81. {
  82. return GetValueOf((ConstantExpression)expression);
  83. }
  84.  
  85. throw new InvalidOperationException(UnsupportedExpression);
  86. }
  87.  
  88. private static object GetValueOf(MemberExpression expression)
  89. {
  90. var container = GetValueOf(expression.Expression);
  91.  
  92. if (expression.Member is FieldInfo)
  93. {
  94. return ((FieldInfo)expression.Member).GetValue(container);
  95. }
  96. else if (expression.Member is PropertyInfo)
  97. {
  98. return ((PropertyInfo)expression.Member).GetValue(container);
  99. }
  100.  
  101. throw new InvalidOperationException(UnsupportedExpression);
  102. }
  103.  
  104. private static object GetValueOf(ConstantExpression expression)
  105. => expression.Value;
  106.  
  107. private static RouteValueDictionary MakeRouteValues(
  108. string actionName, string controllerName, params RouteValue[] arguments)
  109. {
  110. var args = new RouteValue[]
  111. {
  112. new RouteValue("action", actionName),
  113. new RouteValue("controller", controllerName),
  114. };
  115.  
  116. return new RouteValueDictionary(args
  117. .Concat(arguments)
  118. .ToDictionary(o => o.Item1, o => o.Item2));
  119. }
  120.  
  121. #endregion
  122. }

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

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

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).

No hay comentarios:

Publicar un comentario