[ASP.NET MVC] Discriminar acciones según el nombre de los parámetros

El caso que voy a exponer hoy está “basado en hechos reales”, como las películas de Antena 3. Me encontré con la necesidad de ofrecer dos rutas como:

http://mi.com/Form/Show?url=http://geeks.ms

http://mi.com/Form/Show?name=Geeks

Es decir, para quien usa la página, nuestra acción Show puede recibir una URL o bien el nombre de una página que ya tengamos previamente guardada. Son las dos formas de acceder al servicio. Por poner algo más de contexto, puede imaginar el lector un servicio como Google Mobilizer (transformar una página para facilitar su lectura en dispositivos móviles) mezclado con un gestor de marcadores (bookmarks). El uso de la misma ruta en ambos casos es una decisión de diseño que no depende de nosotros.

Este diseño admite dos posibles planteamientos en MVC:

  • Una única acción con dos parámetros opcionales, url y name.
    • Según el parámetro dado, elegir un comportamiento u otro.
    • Decidir qué hacer si nos pasan los dos parámetros (por ejemplo, dar un error).
  • Dos acciones separadas, una con un parámetro url y otra con name.
    • Pro: podemos organizar mejor nuestro código (en cada acción lo suyo).
    • Pro: la decisión la hace MVC, no necesitamos discriminar según los parámetros recibidos. Queda más natural.
    • Contra: no es soportado de forma nativa en MVC 3, como veremos.

Como el código de ambas acciones no tiene apenas nada en común, me gustaba más la segunda opción, por lo que vamos a ver si podemos ir salvando los obstáculos que nos encontremos en el desarrollo del ejemplo. Para comenzar:

  1. Creamos un nuevo proyecto ASP.NET MVC 3 de nombre SelectingActionByArgumentsName.
  2. Usamos la plantilla Vacía (Empty) y motor de vistas Razor (View engine).
  3. Añadimos un nuevo controlador: clic derecho en la carpeta Controllers, y elegimos Agregar (Add) > Controller.
  4. Le llamamos FormController, y lo creamos vacío (Empty controller).

Ya tenemos el proyecto listo para incluir nuestras dos acciones. Bajo la acción Index que se genera siempre, añadimos las dos nuestras, devolviendo sólo un contenido (para simplificar):

public ActionResult Show(string url)
{
    return Content("Loaded from url");
}

public ActionResult Show(string name)
{
    return Content("Loaded from name");
}

Y encontramos el primer obstáculo:

C# no admite dos métodos con la misma firma en una clase.

Evidentemente, aunque el nombre de los parámetros sea distinto, son del mismo tipo, por lo que C# (no ASP.NET MVC, sino el compilador) nos dará un error. Contra esto, poco podemos hacer, hay que cambiar algo. Pero existe una solución sencilla: añadir un parámetro opcional. Esto hará que los métodos sean diferentes. Por ejemplo, vamos a añadir un parámetro opcional save a la acción que recibe una url:

http://mi.com/Form/Show?url=http://geeks.ms&saveAs=Geeks

Este parámetro indica que queremos que se guarde la dirección con ese nombre. De hecho, hemos aprovechado para añadir funcionalidad, pero en la práctica podríamos haber añadido un parámetro sin utilidad y no usarlo nunca (su única utilidad sería permitir que el compilador acepte nuestros dos métodos de forma válida). Y lo haremos opcional, por lo que la URL inicial seguirá funcionando. Así que cambiamos la primera acción por:

public ActionResult Show(string url, string saveAs = "")

Y ya tenemos el primer obstáculo salvado: el compilador no se queja. Ya podemos ejecutar. Pero si navegamos a (en xxx irá el puerto de vuestro servidor web):

http://localhost:xxx/Form/Show?name=Geeks

Recibimos un error ahora de ASP.NET MVC (segundo obstáculo):

The current request for action ‘Show’ on controller type ‘FormController’ is ambiguous between the following action methods:

System.Web.Mvc.ActionResult Show(System.String, System.String) on type SelectingActionByArgumentsName.Controllers.FormController

System.Web.Mvc.ActionResult Show(System.String) on type SelectingActionByArgumentsName.Controllers.FormController

ASP.NET MVC no sabe elegir qué acción utilizar. ¿Pero no hemos puesto name en nuestra URL? Pues usa la acción que tiene un argumento name, so tonto (no sé si vosotros también soléis insultar a la pantalla). Pero no: ASP.NET MVC no usa los nombres de los argumentos para elegir qué acción llamar durante el proceso de enrutado (por defecto, que como casi todo en MVC 3, esto puede cambiarse). Ojo, sí usa los nombres después para asignar los parámetros, pero eso es cuando ya ha elegido la acción; es para elegirla cuando no los considera. Sólo sus tipos. Y usando sólo los tipos, se encuentra con esa ambigüedad.

Pero como hemos dicho, casi todo en MVC 3 puede cambiarse, y además de diferentes formas. En este caso, vamos a usar una solución vista en StackOverflow, aunque modificada para hacerla más genérica, dirigida a cambiar el mecanismo de selección de acciones (sí, ese que hemos dicho que se basa en los tipos) para que considere también los nombres de los argumentos. Y además vamos a hacerlo mediante un atributo para que sólo afecte a las acciones donde lo necesitemos. Para esto, sólo debemos crear una clase que extienda de ActionMethodSelectorAttribute y sobreescribir el método IsValidForRequest, que es quien realizará la comprobación.

public class SelectByArgumentNamesAttribute : ActionMethodSelectorAttribute {
    public override bool IsValidForRequest(ControllerContext controllerContext, 
                                           MethodInfo methodInfo) {
        return methodInfo.GetParameters()
                .All(pi => pi.IsOptional 
                        || controllerContext.HttpContext.Request[pi.Name] != null);
    }
}

Un detalle aclaratorio: no tenemos que comprobar el nombre del método, ya que esto lo hace ASP.NET MVC antes de llamar a esta validación. Sólo tenemos que comprobar si, según los datos de la petición (Request), este método (methodInfo) es el apropiado o no. Por lo que comprobamos que todos los argumentos del método vienen en la Request (con su nombre), salvo que sean opcionales.

Ahora sólo tenemos que usar este atributo en las acciones conflictivas (en ambas). Por ejemplo:

[SelectByArgumentNames]
public ActionResult Show(string url, string saveAs = "") {
…

Tras usar el atributo en las dos acciones, ya podemos probar a navegar a las URL:

http://localhost:xxx/Form/Show?url=http://geeks.ms

http://localhost:xxx/Form/Show?name=Geeks

¿Pero qué sucede? Que todavía nos queda por salvar el tercer obstáculo, aunque no está relacionado con lo anterior. Si comprobáis, veréis que funciona correctamente con la primera dirección (con o sin el parámetro opcional) pero falla con la segunda, la que usa el parámetro name.

¿Por qué? Bueno, pues este es uno de esos problemas que nos invitan a creer en la magia y abandonar la programación para dedicarnos a echar las cartas, hasta que descubrimos de qué se trata: la culpa es del nombre elegido para el parámetro url, dado que siempre que preguntemos por Request[“url”] obtendremos un valor, nunca es null, aunque no hayamos pasado ningún parámetro con ese nombre en la URL, porque el valor obtenido es ¡el de la propia URL de la petición!

Por suerte, este obstáculo es el de más fácil solución: en lugar de buscar en Request, profundizamos un poco más hasta Request.QueryString, donde sólo están los parámetros que vienen en la QueryString, con lo que sólo habrá un url si se lo pasamos, y si no devolverá null. La solución pasa por cambiar:

controllerContext.HttpContext.Request[pi.Name] != null

por

controllerContext.HttpContext.Request.QueryString[pi.Name] != null

Y resuelto. He querido incluir este último obstáculo por 1. ser fiel a la realidad (recordad que el artículo está basado en hechos reales) y 2. por la moraleja, parafraseando: “el más mínimo desconocimiento de la herramienta puede hacerse pasar por magia”.

En conclusión, hemos visto cómo se puede cambiar de forma muy sencilla la forma en que ASP.NET MVC selecciona la acción a partir de la URL. Recordad que, igualmente, muchos otros comportamientos generales se pueden modificar y adaptar a los casos concretos, o incluso esto podíamos haberlo conseguido de otras formas. Y sobre todo, lo más importante: hay que conocer bien la herramienta con la que trabajamos para adaptarla, entenderla… y distinguirla de la magia.

Un placer.