#232 Routing Walkthrough Part 2
Este episodio es una continuación del de la semana pasada y en él seguiremos examinando la implementación del código de rutas en Rails 3. Al final del episodio anterior el fichero de rutas de nuestra aplicación tenía el siguiente aspecto:
Store::Application.routes.draw do match 'products', :to => ProductsController.action("index") match 'products/recent' end
En el episodio anterior estudiamos la implementación del método match
y vimos lo que ocurre cuando se invoca match
en el fichero de rutas. En este episodio estudiaremos el resto de métodos disponibles para su uso en el fichero de rutas.
Si miramos el código fuente de Rails 3.0 veremos que la lógica de las rutas está en la carpeta actionpack/lib/actiondispatch/routing
y de todos los archivos nos centraremos en el de la clase Mapper
porque, como ya vimos en el episodio anterior, el bloque dentro del fichero de rutas se ejecuta en el ámbito de esta clase. Esto quiere decir que cualquier método que sea llamado en dicho bloque se ejecuta en una instancia de Mapper
y por tanto en nuestros ficheros de rutas podemos invocar cualquier método de dicha clase.
El código de la clase Mapper
puede resultar abrumador. Son casi 1000 líneas de código complejo, pero la buena noticia es que se trata del fichero más grande de todo el sistema de rutas de Rails por lo que si somos capaces de entender el funcionamiento de esta clase habremos conseguido entender bastante bien cómo funcionan las rutas en Rails.
Para ver el código a vista de pájaro lo colapsaremos con la funcionalidad de plegado de código de TextMate. Si pulsamos Command-Option-0
el fichero se plegará al máximo. A continuación expandiremos el módulo raíz ActionDispatch
, su submódulo Routing
y por último la clase Mapping
propiamente dicha.
Los primeros dos elementos de la clase Mapper
son las definiciones de clase de Constraints
y Mapping
. Ya las vimos en el episodio anterior pero lo que merece la pena ver ahora es que estas clases están anidadas dentro de la clase Mapper
. ¿Por qué anidar las clases de esta manera? No hay nada mágico aquí: la clase Constraints
está totalmente desacoplada de la clase Mapper
. El motivo del anidamiento es porque así los nombres delas clases Constraints
y Mapping
quedan definidos dentro del ámbito del espacio de nombres de Mapper
. En Ruby no hay ningún tipo de herencia ni relación entre las clases anidadas de esta forma.
Si bajamos hasta la clase tenemos dos métodos, self.normalize_path
y self.normalize_name
, que son métodos de utilidad que se invocan dentro de la clase. Debajo hay un grupo de módulos:
module Base... module HttpHelpers... module Scoping... module Resources... module Shorthand... include Base include HttpHelpers include Scoping include Resources include Shorthand
Estos cinco módulos están incluidos dentro de la clase Mapper
. Su código está en módulos simplemente para organizar mejor el código de la clase.
Base
Ya vimos el primer módulo, Base
, en el episodio anterior. Contiene los métodos match
y root
(que utiliza a match
), así como el método mount
que nos da una manera alternativa de mapear una aplicación Rack sobre una URL.
module Base def initialize(set) #:nodoc: def root(options = {}) match '/', options.reverse_merge(:as => :root) end def match(path, options=nil)... def mount(app, options = nil)... def default_url_options=(options)... alias_method :default_url_options, :default_url_options= end
HttpHelpers
El siguiente módulo es HttpHelpers
, que es donde se definen los métodos get
, post
, put
y delete
. Estos métodos se utilizan para mapear las rutas a ciertos tipos de petición.
module HttpHelpers def get(*args, &block) map_method(:get, *args, &block) end def post(*args, &block) map_method(:post, *args, &block) end def put(*args, &block) map_method(:put, *args, &block) end def delete(*args, &block) map_method(:delete, *args, &block) end def redirect(*args, &block)... private def map_method(method, *args, &block) options = args.extract_options! options[:via] = method args.push(options) match(*args, &block) self end end
Todos los métodos invocan al método privado map_method
, que se encarga de establecer la opción :via
de acuerdo al método recibido como argumento y a continuación invoca a match
. Nótese que en el código de rutas gran parte de los métodos delegan sobre el método match
manipulando sus diferentes opciones. Por tanto si queremos que una ruta sólo responda a una petición GET podríamos hacerlo de esta manera, con la opción via
.
match 'products/recent', :via => :get
En la práctica haríamos esto utilizando la opción get
, que creará una ruta con dicha opción.
get 'products/recent'
Los métodos post
, put
y delete
funcionan de la misma manera que el método get
para los otros tipos de petición. Sin embargo el método redirect
se distingue de los otros porque devuelve una aplicación Rack, lo que es más interesante.
def redirect(*args, &block) options = args.last.is_a?(Hash) ? args.pop : {} path = args.shift || block path_proc = path.is_a?(Proc) ? path : proc { |params| path % params } status = options[:status] || 301 lambda do |env| req = Request.new(env) params = [req.symbolized_path_parameters] params << req if path_proc.arity > 1 uri = URI.parse(path_proc.call(*params)) uri.scheme ||= req.scheme uri.host ||= req.host uri.port ||= req.port unless req.standard_port? body = %(<html><body>You are being <a href="#{ERB::Util.h(uri.to_s)}">redirected</a>.</body></html>) headers = { 'Location' => uri.to_s, 'Content-Type' => 'text/html', 'Content-Length' => body.length.to_s } [ status, headers, [body] ] end end
El método construye la aplicación Rack devuelta con un array formado por un valor de estado, algunas cabeceras y el cuerpo. El código de estado por defecto vale 301
, que hará que el navegador efectúe una redirección 301 Moved Permanently
. Podemos utilizar el método redirect
directamente en nuestro archivo de rutas si queremos que una URL redirija a otra. En nuestra fichero de rutas ya existe una ruta que utiliza el parámetro :to
, y dicho parámetro recibe una aplicación Rack
match 'products', :to => ProductsController.action("index")
Dado que la opción redirect
devuelve una aplicación Rack la podemos utilizar para redirigir a una nueva URL de esta forma:
match 'products', :to => redirect("/items")
Esta funcionalidad resulta bastante útil cuando estamos cambiando las URLs de nuestra aplicación pero queremos seguir soportando las URLs anteriores. Se puede usar redirect
para dirigir estas URLs antiguas a las nuevas.
Abreviaturas
Los siguientes módulos que aparecen son Scoping
y Resources
, que veremos en breve. Pero de momento nos fijaremos en el módulo Shorthand
. Se trata de un módulo de interés porque enriquece al método match
, que venía definido en el módulo Base
. Este método match
soporta una sintaxis diferente para las opciones que puede recibir. El método abreviado es una forma alternativa de escribir la opción :to
en una ruta, como hicimos en la ruta redirect
que escribimos anteriormente.
match 'products', :to => redirect('/items')
Se trata de algo habitual en los ficheros de ruta, y la sintaxis abreviada nos permite escribir la ruta con un hash sencillo construido a partir de la ruta y a dónde debería apuntar. Al igual que con la sintaxis de ruta completa podemos añadir parámetros al final de la ruta
match 'products' => redirect('/items')
El método match
de Shorthand
establece el valor del párámetro :to
si no ha sido establecido aún. A continuación invoca a super
pero... si Mapper
no hereda de ninguna otra clase, ¿qué se supone que tiene que hacer la llamada a super
en este caso?
rails/actionpack/lib/action_dispatch/routing/mapper.rb
module Shorthand def match(*args) if args.size == 1 && args.last.is_a?(Hash) options = args.pop path, to = options.find { |name, value| name.is_a?(String) } options.merge!(:to => to).delete(path) super(path, options) else super end end end
Cuando en Ruby se usa el método super
de esta manera, el intérprete buscará un método con el mismo nombre que haya sido definido en un módulo anterior. El módulo Shorthand
es el último que se define en la lista de módulos incluidos en Mapper
por lo que Ruby buscará un método match
definido en los módulos anteriores para delegar la llamada. En este caso, invocará a match
en el módulo Base
.
Esta técnica se utiliza con frecuencia en el código fuente de Rails 3. Las versiones anteriores de Rails utilizaban alias_method_chain
para redefinir comportamientos específicos pero ahora con Rails 3 se puede utilizar simplemente super
.
Recursos
A continuación veremos el módulo Resources
Como sería de esperar este módulo contiene el método resources
y todos los métodos asociados. Este método se usa en los ficheros de rutas para establecer rutas de tipo REST.
def resources(*resources, &block) options = resources.extract_options! if apply_common_behavior_for(:resources, resources, options, &block) return self end resource_scope(Resource.new(resources.pop, options)) do yield if block_given? collection_scope do get :index if parent_resource.actions.include?(:index) post :create if parent_resource.actions.include?(:create) end new_scope do get :new end if parent_resource.actions.include?(:new) member_scope do get :edit if parent_resource.actions.include?(:edit) get :show if parent_resource.actions.include?(:show) put :update if parent_resource.actions.include?(:update) delete :destroy if parent_resource.actions.include?(:destroy) end end self end
Se trata de un método ciertamente complejo pero se puede enteder su funcionamiento a través de su estructura general. Hay un par de métodos de colección, get :index
y post :create
, hay un método get :new
y finalmente get :edit
, get :show
, put :update
y por último delete :destroy
. Deberían resultar familiares porque son las famosas siete acciones REST que se crean para un controlador cuando se invoca el método resources
en el fichero de rutas.
Obsérvese la primera línea en el bloque resource_scope
del método. Si se pasa un bloque a este método lo primero que hará será un yield
sobre dicho bloque antes de crear las acciones REST. Esto nos permite crear nuestras propias acciones en el fichero de rutas. Por ejemplo podríamos añadir una nueva ruta de colección que devuelva los productos con descuento.
Store::Application.routes.draw do match 'products', :to => redirect('/items') get 'products/recent' resources :products do collection do: get :discounted end end end
El código dentro del bloque que se pasa a resources
será ejecutado por la llamada yield
en resource_scope
, tras lo cual se definen las siete rutas REST.
Si vemos los bloques del fichero de rutas de arriba podríamos pensar que cada vez que creamos un nuevo bloque el objeto Mapper
cambia pero no es este el caso por lo que llamar a get
en el bloque de mayor anidamiento es lo mismo que hacerlo fuera. Lo que se maneja es un ámbito diferente.
Si se le echa otro vistazo al método resources
en el código fuente de Rails veremos que hace una llamada a collection_scope
cuando define las acciones index
y create
pero en nuestro fichero de rutas tan sólo usamos collection
. ¿Cuál es la diferencia? No mucha. Si miramos el método collection
de la clase Mapper
veremos que delega en collection_scope
.
def collection unless @scope[:scope_level] == :resources raise ArgumentError, "can't use collection outside resources scope" end collection_scope do yield end end
Echemos otro vistazo a nuestro fichero de rutas.
Store::Application.routes.draw do match 'products', :to => redirect('/items') get 'products/recent' resources :products do collection do: get :discounted end end end
Ambas llamadas invocan al mismo método pero la que está dentro del bloque collection
asumirá un comportamiento adicional según su ámbito dentro de los bloques resources
y collection
.
Si regresamos al módulo Resources
nos encontraremos con viejo conocido, el método match
, en cuya redefinición se añade el comportamiento adicional basado en recursos:
def match(*args) options = args.extract_options!.dup options[:anchor] = true unless options.key?(:anchor) if args.length > 1 args.each { |path| match(path, options.dup) } return self end on = options.delete(:on) if VALID_ON_OPTIONS.include?(on) args.push(options) return send(on){ match(*args) } elsif on raise ArgumentError, "Unknown scope #{on.inspect} given to :on" end if @scope[:scope_level] == :resources args.push(options) return nested { match(*args) } elsif @scope[:scope_level] == :resource args.push(options) return member { match(*args) } end action = args.first path = path_for_action(action, options.delete(:path)) if action.to_s =~ /^[\w\/]+$/ options[:action] ||= action unless action.to_s.include?("/") options[:as] = name_for_action(action, options[:as]) else options[:as] = name_for_action(options[:as]) end super(path, options) end
La línea que comprueba el ámbito actual para ver si es resources
aparece más o menos a la mitad del bloque. En tal caso, añade un comportamiento diferente. La lógica es bastante compleja pero lo único que nos hace falta saber es que el módulo Resources
redefine el método match
. Obsérvese que al final vuelve a invocar al método super
para invocar al método match
en Base
. Recordemos que get
invoca a match
y es aquí donde se ubica la funcionalidad adicional para get
y los otros métodos que están definidos en resources
.
Ámbitos
Ya hemos llegado al último método de la clase Mapping
: Scoping
. Siempre que hay un bloque dentro de nuestro archivo de rutas hay una llamada implícita al método scope
de Scoping
. Esto quiere decir que definirá cierto comportamiento adicional para el código que está incluido dentro de ese bloque.
Junto al método scope
hay otros métodos, que delegan todos sobre scope
.
def initialize(*args) #:nodoc: @scope = {} super end def controller(controller, options={}) options[:controller] = controller scope(options) { yield } end def namespace(path, options = {}) path = path.to_s options = { :path => path, :as => path, :module => path, :shallow_path => path, :shallow_prefix => path }.merge!(options) scope(options) { yield } end def constraints(constraints = {}) scope(:constraints => constraints) { yield } end def defaults(defaults = {}) scope(:defaults => defaults) { yield } end
Todos estos métodos son bastante sencillos y delegan sobre un método más genérico estableciendo primero algunas opciones. Por ejemplo defaults
invoca a scope
después de establecer opciones por defecto, e igualmente constraints
invoca a scope
con ciertas opciones de restricción. El método namespace
es un poco más complicado pero en esencia hace lo mismo. El módulo también incorpora un método initialize
que sólo crea una variable de instancia (@scope
) y le asigna un hash vacío. Cabe preguntarse el motivo de un método initialize
en un módulo, ya que los módulos no se instancian. Esto es cierto, pero en este caso estamos redefiniendo el comportamiento de un método. Cuando el módulo Scoping
se incluye en la clase Mapper
este método initialize
reemplazará al ya existente añadiendo la variable @scope
y luego invocando a super
.
Por último tenemos el método scope
propiamente dicho que es donde tiene lugar todo esto. Se trata por tanto de un método bastante complejo pero en esencia lo que hace es rellenar la variable @scope
con la información del ámbito en el que se encuentra para que pueda ser usada dentro de cualquier invocación a match
que tengamos.
Con esto tenemos el funcionamiento básico de los bloques en el fichero de rutas. Si definimos nuestras rutas así:
Store::Application.routes.draw do controller :products do match #... end end
Cada vez que se llame a match
en el bloque controller
(y recordemos que delega a scope
) la opción controller
se propagará automáticamente.
Eso es todo por este episodio. Esperamos que sirva para tener una idea de lo que hacen los distintos métodos disponbibles en el fichero de rutas. Aunque hay muchos métodos disponibles la mayoría de ellos simplemente delegan en match
o scope
pasándoles ciertas opciones adicionales.