Follow me
RSS feed
My sources
My Viadeo

Créer son microframework avec Rack (Partie 2)

Greg | 05 Mar 2009

projetsHier, nous avons terminé la journée avec un début de framework...

Passons en revue ce qu'il nous reste à faire.

Nous avons fait en sorte que les routes soient formatées sous forme d'expression rationnelle. Mais nous ne nous en sommes pas encore servi. En fait, je ne suis pas trop rentré dans le détail quand nous avons mis en place la méthode __urls__. Ce n'est pas grave, vous et moi avons gagné du temps, car, comme vous allez le voir, j'ai fait quelques modifications.

Et bien c'est tout. En effet, si nous voulons être conforme à l'exemple que j'avais donné, il ne restait que cela à traiter.

Dans la réalité, j'en ai fait un peu plus.

S'il y a bien une chose que je déteste, ce sont les pages d'erreur 404 par défaut. Et celle de Rack est une perle en la matière. Je vous propose donc d'ajouter la possibilité de customiser cette page. Et tant que nous y sommes, faisons-le également pour les erreurs 500 et 501.

Enfin, nous allons ajouter la gestion des sessions et un petit helper pour faciliter la mise en place des redirections.

Et les vues ? Et les modèles ?

Que nenni, comme je vous l'ai dit dans la première partie, l'objectif est d'avoir un backend pour des applications Cappuccino, alors pour les vues... En ce qui concerne les modèles, vous avez suffisamment de choix. C'est vrai, c'est facile les bases de données1. Mais bon, pour les sceptiques, si vous voulez, nous terminerons en fabriquant un petit blog2.

Revenons à nos routes. Nous avons mis en place la méthode Capcode.Route, prenant en paramètre un tableau de routes formatées sur un modèle d'expression régulière. L'idée était que pour une déclaration de ce type :

module Capcode
  class Hello < Route '/hello/(.*)'
    def get( arg )
      ...
    end
  end
  class World < Route '/world/([^\/]*)/(.*)'
    def get( arg1, arg2 )
      ...
    end
  end
end

Dans la méthode get de la classe Hello, si vous avons utilisé le chemin /hello/world alors, le paramètre arg contiendra la valeur world. De même, si nous utilisons le chemin /world/of/love, dans World.get, arg1 vaut of et arg2 vaut love. Maintenant, nous devons faire un choix. Que va-t-il se passer son nous utilisons les chemins /hello et /world/map ? Dans ces deux cas, je vous propose3 tout simplement que l'application se vautre4. La question qui se pose maintenant c'est comment doit réagir l'application, si nous utilisons le chemin /world/on/fire avec la déclaration suivante :

module Capcode
  class World < Route '/world/([^\/]*)/(.*)', '/world/on/(.*)'
    def get( arg1, arg2 )
      ...
    end
  end
end

Nous savons déjà que Rack prendre la route la plus adaptée. Donc dans le cas présent /world/of. Dans ce cas nous ne pouvons (et ne devons) capturer qu'une seule valeur. Or la méthode get en attend deux. Qu'à cela ne tienne, nous avons qu'à envoyer deux valeurs : la première étant celle capturée et la seconde sera tout simplement nil. Parce que finalement, si un développeur éprouve le besoin de faire un truc aussi tordu, il sera bien se débrouiller :|

Pour commencer, nous avons besoin de modifier un peu la méthode __urls__ de la classe renvoyée par Route. En effet, dans un cas comme celui que nous venons de voir, cette méthode renvoyait le tableau suivant :

[{'/world' => '([^\/]*)/(.*)', '/world/on' => '(.*)'}, Capcode::World]

Dans un cas nous nous attendons à capturer deux valeurs et dans l'autre une seule. Donc notre méthode get devra attendre deux paramètres. Mais pour savoir cela, et donc pouvoir faire l'appel à la méthode get dans la méthode call, il faut le déterminer. La seule solution consiste donc à lire chacune des regexp et à déterminer le nombre maximum de captures. Pour faire cela, je vous propose que nous fassions un petit hack de la classe Regexp. En fait, nous allons créer une méthode Regexp.number_of_captures qui nous donnera, pour l'objet Regexp le nombre de captures attendues :

class Regexp
  def number_of_captures
    c, x = 0, self.source.dup.gsub( /\\\(/, "" ).gsub( /\\\)/, "" )
    while( r = /(\([^\)]*\))/.match( x ) )
      c, x = c+1, r.post_match
    end
    c
  end
end

Ayant cela, nous allons modifier la déclaration de la méthode __urls__ de façon à renvoyer dans le tableau de sortie le nombre maximum de captures pour l'ensemble des routes du contrôleur :

module Capcode
  ...
  class << self
    ...
    def Route *u
      Class.new {
        meta_def(:__urls__){
          # < Route '/hello/world/([^\/]*)/id(\d*)', '/hello/(.*)'
          # # => [ {'/hello/world' => '([^\/]*)/id(\d*)', '/hello' => '(.*)'}, 2, <Capcode::Klass> ]
          h = {}
          max = 0
          u.each do |_u|
            m = /\/([^\/]*\(.*)/.match( _u )
            if m.nil?
              h[_u] = ''
            else
              h[m.pre_match] = m.captures[0]
              max = Regexp.new(m.captures[0]).number_of_captures if max < Regexp.new(m.captures[0]).number_of_captures
            end
          end
          [h, max, self]
        }
      }
      ...
    end
    ...
  end
  ...
end

Donc si nous reprenons notre exemple. Avec cette nouvelle version, __urls__ nous renverra cette fois-ci le résultat suivant :

[{'/world' => '([^\/]*)/(.*)', '/world/on' => '(.*)'}, 2, Capcode::World]

Nous pouvons maintenant modifier la méthode call. Mais avant cela, vous l'avez peut-être oublié, mais nous utilisons également la méthode __urls__ dans la méthode Capcode.run. En effet, c'est garce à elle que nous complétons la table des routes. Il ne faudra pas oublier de faire la correction. Je ne rentre pas dans le détail. Vous avez compris, et puis vous verrez cela lorsque je vous présenterai le code complet.

Jusqu'à maintenant, dans call nous somme resté très basique. En effet, nous vérifions le type de la requête et en fonction, nous appelons la méthode get ou post de notre contrôleur. Pour ce qui est du POST, nous pouvons laisser comme cela. En effet, nous ne sommes pas sensés envoyé de quelconques paramètres vie l'URL dans un POST. Pour le GET par contre, nous devons faire les éventuelles captures. La capture en elle-même n'a rien de bien compliqué. Nous devons simplement appliquer l'expression rationnelle rattachée au script_name sur le path_info. Ces deux dernières informations se récupèrent via la requête. Or souvenez-vous, nous avons récupéré l'objet Rack::Request au début de la méthode call. L'expression rationnelle quant à elle, sera récupérée via la méthode __urls__ :

sc = @request.script_name
sc = "/" if sc.size == 0
regexp = Regexp.new( self.class.__urls__[0][sc] )
nargs = self.class.__urls__[1]
              
args = regexp.match( Rack::Utils.unescape(@request.path_info).gsub( /^\//, "" ) )
if args.nil?
  raise Capcode::ParameterError, "Path info `#{@request.path_info}' does not match route regexp `#{regexp.source}'"
else
  args = args.captures
end

while args.size < nargs
  args << nil
end

A la fin de cet enchaînement, nous avons le tableau des valeurs des paramètres à passer à la méthode get. Vous remarquerez que l'on stocke la valeur du script_name dans la variable sc. Ceci afin de pouvoir tester qu'il ne s'agit pas d'une chaîne vide, et au besoin lui donner la valeur /. En effet, si vous passez par l'URL http://localhost:3000, alors le script_name est vide...

La méthode get n'attendant pas un tableau, mais bien des paramètres distincts, nous l'appellerons de la façon suivante :
get( *args )

Passons maintenant à la gestion des erreurs. Le plus simple pour cela est de faire un middleware Rack. Un middleware est une peu comme un contrôleur, mais il n'est pas associé à une route, mais utilisé lors de la création de l'application. Souvenez vous, dans la méthode Capcode.run nous enchainons plusieurs appels de middleware :

app = Rack::URLMap.new(@@__ROUTES)
app = Rack::ContentLength.new(app)
app = Rack::Lint.new(app)
app = Rack::ShowExceptions.new(app)
app = Rack::CommonLogger.new( app, Logger.new(conf[:log]) )

Nous allons donc en ajouter un à ce niveau, qui va se charger d'intercepter les erreurs.

Pour être cohérents, nous allons créer notre middleware dans le module Capcode. Cela ne devrait poser aucun problème puisque Capcode inclut le module Rack

Comme je l'ai dit, un middleware à la même tête qu'un contrôleur. La principale différence est que nous devons créer une méthode d'initialisation. Cette méthode prendra en paramètre l'application, en fera ce qu'il à a en faire et la stockera dans la variable de classe @app. Dans notre cas, nous n'avons rien de particulier à faire avec l'application. Nous interviendrons lors de l'appel de la réponse, donc dans la méthode call5. A ce niveau-là, nous allons récupérer le résultat du call de l'application, qui nous renverra un tableau contenant le statut de la réponse, le contenu de l'entête et le corps de la réponse. Si le statut est un 404, 500 ou 501, nous vérifierons si une méthode de surcharge existe pour l'erreur donnée, nous l'exécuterons et nous renverrons le tableau contenant le statut, le contenu de l'entête et le corps de la réponse modifié. Voici le squelette de notre middleware :

module Capcode
  ...
  class HTTPError
    def initialize(app)#:nodoc:
      @app = app
    end

    def call(env) #:nodoc:
      status, headers, body = @app.call(env)
      
      ...

      [status, headers, body]
    end
  end
  ...
end

Si nous laissons cela en l'état, nous avons alors fabriqué le premier middleware qui ne fait rien ! Nous allons donc ajouter les quelques lignes de code qui appellent la bonne fonction pour une valeur de statut donnée. Dans ces fonctions (une pour chaque code), nous allons modifier uniquement le corps de la réponse. Nous devrons également mettre à jour la valeur du Content-Length dans l'entête. Concernant les fonctions de surcharge, je vous propose6 de les appeler r404, r500 et r501. Donc ce que nous devons faire, c'est vérifier que ces méthodes existent et les appeler si c'est le cas. Sinon nous ne faisons rien. Voici donc notre middleware complet :

module Capcode
  ...
  class HTTPError
    def initialize(app)#:nodoc:
      @app = app
    end

    def call(env) #:nodoc:
      status, headers, body = @app.call(env)
      
      if self.methods.include? "r#{status}"
        body = self.call "r#{status}", env['REQUEST_PATH']
        headers['Content-Length'] = body.length.to_s
      end

      [status, headers, body]
    end
  end
  ...
end

Remarquez que nous passons à chaque méthode de surcharge la valeur du REQUEST_PATH, soit le chemin. Il nous suffit maintenant de rajouter ce middleware lors de la création de l'application dans Capcode.run. Coté l'utilisation, il suffit d'ajouter une méthode (r404, r500, r501) dans la classe Capcode::HTTPError :

require 'capcode'

module Capcode
  class HTTPError
    def r404(f)
      "Pas glop !!! #{f} est inconnu !!!"
    end
  end
  
  class Hello < Route '/time'
    def get( you )
       "Hello, it's '#{Time.now} !"
    end
  end
end

Capcode.run( :port => 3001, :host => "localhost" )

Réglons le cas des sessions maintenant. Il existe un middleware Rack pour gérer cela. En fait, il en existe plusieurs permettant de gérer des sessions via les cookies ou via memcached. Je ne retiendrai que la première solution, évitant ainsi aux utilisateurs de devoir installer memcached. Il suffit donc simplement d'ajouter le middleware Rack::Session::Cookie dans Capcode.run. Je me suis aussi amusé à ajouter une méthode session dans la classe renvoyée par Capcode.Route afin de faciliter l'utilisation de ces sessions. En effet, l'accès à la session se fait via la clé rack.session de l'environnement de l'application. J'ai donc ajouté le code suivant dans la classe :

def session
  @env['rack.session']
end

Terminons maintenant par la création d'un nouveau helper permettant de mettre en place des redirections. L'idée est la suivante. Dans un contrôleur, nous devons pouvoir ajouter un appel de méthode qui entraînera une redirection vers un contrôleur ou une URL donnée. Voici quelques exemples d'utilisation :

redirect( MonControleur )
# => Statut = 302, Location = /chemin

redirect( MonControleur, "hello", "world" )
# => Statut = 302, Location = /chemin/hello/world

redirect( "/hello" )
# => Statut = 302, Location = /hello

redirect( "http://monsite.com", "hello", "world" )
# => Statut = 302, Location = http://monsite.com/hello/world

Je pense que vous avez compris l'idée. Pour faire cela, nous avons besoin de modifier un peu la méthode call de la classe renvoyée par Capcode.Route. En effet, le helper redirect entraîne des modifications dans le statut et l'entête de la réponse. Or, pour le moment, la méthode call attend en retour de get ou post une chaîne correspondant au corps de la réponse. Nous allons donc modifier la méthode call de telle sorte qu'elle accepte une chaîne ou un tableau en retour de get et post. Si le résultat est une chaîne, alors nous considérerons qu'il s'agit du contenu du corps de la réponse. Si c'est un tableau, nous considérerons que la première valeur est le statut, la seconde un hachage contenant des données pour l'entête et le dernier est le corps de la réponse. Voici donc la nouvelle version de la méthode call :

def call( e ) #:nodoc:
  @env = e
  @response = Rack::Response.new
  @request = Rack::Request.new(@env)

  r = case @env["REQUEST_METHOD"]
    when "GET"
      sc = @request.script_name
      sc = "/" if sc.size == 0
      regexp = Regexp.new( self.class.__urls__[0][sc] )
      nargs = self.class.__urls__[1]
      
      args = regexp.match( Rack::Utils.unescape(@request.path_info).gsub( /^\//, "" ) )
      if args.nil?
        raise Capcode::ParameterError, "Path info `#{@request.path_info}' does not match route regexp `#{regexp.source}'"
      else
        args = args.captures
      end
      
      while args.size < nargs
        args << nil
      end
                                  
      get( *args )
    when "POST"
      post
  end
  if r.respond_to?(:to_ary)
    @response.status = r[0]
    r[1].each do |k,v|
      @response[k] = v
    end
    @response.body = r[2]
  else
    @response.write r
  end
  
  @response.finish
end

Pour le helper redirect, nous avons simplement besoin qu'il renvoi un tableau avec comme valeur de statut : 302, un champ Location contenant l'URL de redirection, et un corps vide :

module Capcode
  ...
  module Helpers
    ...
    def redirect( klass, *a )
      [302, {'Location' => URL(klass, *a)}, '']
    end
    ...
  end
end

Tout repose donc sur la méthode URL. C'est vrai quoi. Tant qu'à faire un helper, autant en faire deux... En effet URL sera lui aussi un helper que nous pourrons donc utiliser pour gérer les liens. Par exemple :

...
"<a href='#{URL(MonController, *args)}'>lien</a>"
...

La méthode URL URL prend donc en premier paramètre la classe du contrôleur ou le chemin et un tableau de paramètre à ajouter dans le chemin. Dans cette méthode, il faut donc commencer par déterminer si nous avons reçu une classe contrôleur ou un chemin. Facile ! Si la classe du premier paramètre est de type Class, c'est une class 8O Sinon c'est un chemin. Si c'est une classe, c'est donc un contrôleur, il faut rechercher son chemin. Pour cela il suffit de parcourir la table des routes (@@__ROUTES). Malheureusement nous sommes dans le module Capcode::Helpers et nous n'avons pas accès à cette table. Nous allons donc rajouter une méthode au module Capcode afin de pouvoir la récupérer :

module Capcode
  ...
  class << self
    ...
    def routes #:nodoc:
      @@__ROUTES
    end
    ...
  end
end

Maintenant que nous avons cela, nous pouvons mettre en place la méthode URL :

module Capcode
  ...
  module Helpers
    ...
    def URL( klass, *a )
      path = nil
      a = a.delete_if{ |x| x.nil? }
      
      if klass.class == Class
        Capcode.routes.each do |p, k|
          path = p if k.class == klass
        end
      else
        path = klass
      end
      
      path+((a.size>0)?("/"+a.join("/")):(""))
    end
    ...
  end
end

Et nous avons terminé. Voici donc la nouvelle version de Capcode :

require 'rubygems'
require 'rack'
require 'json'
require 'logger'

class Object
  def meta_def(m,&b)
    (class<<self
self end).send(:define_method,m,&b)
  end
end

class Regexp
  def number_of_captures
    c, x = 0, self.source.dup.gsub( /\\\(/, "" ).gsub( /\\\)/, "" )
    while( r = /(\([^\)]*\))/.match( x ) )
      c, x = c+1, r.post_match
    end
    c
  end
end

module Capcode
  CAPCOD_VERION="0.2.0"
  @@__ROUTES = {}
  
  class ParameterError < ArgumentError
  end
  
  module Helpers
    def json( d )
      @response['Content-Type'] = 'application/json'
      d.to_json
    end
    
    def redirect( klass, *a )
      [302, {'Location' => URL(klass, *a)}, '']
    end
    
    def URL( klass, *a )
      path = nil
      a = a.delete_if{ |x| x.nil? }
      
      if klass.class == Class
        Capcode.routes.each do |p, k|
          path = p if k.class == klass
        end
      else
        path = klass
      end
      
      path+((a.size>0)?("/"+a.join("/")):(""))
    end
  end
  
  include Rack
  
  class HTTPError
    def initialize(app)
      @app = app
    end

    def call(env)
      status, headers, body = @app.call(env)
      
      if self.methods.include? "r#{status}"
        body = self.call "r#{status}", env['REQUEST_PATH']
        headers['Content-Length'] = body.length.to_s
      end
      
      [status, headers, body]
    end
  end
  
  class << self
    def Route *u
      Class.new {
        meta_def(:__urls__){
          h = {}
          max = 0
          u.each do |_u|
            m = /\/([^\/]*\(.*)/.match( _u )
            if m.nil?
              h[_u] = ''
            else
              h[m.pre_match] = m.captures[0]
              max = Regexp.new(m.captures[0]).number_of_captures if max < Regexp.new(m.captures[0]).number_of_captures
            end
          end
          [h, max, self]
        }

        def params
          @request.params
        end
        
        def env
          @env
        end
        
        def session
          @env['rack.session']
        end
        
        def request
          @request
        end
        
        def response
          @response
        end
        
        def call( e )
          @env = e
          @response = Rack::Response.new
          @request = Rack::Request.new(@env)

          r = case @env["REQUEST_METHOD"]
            when "GET"
              sc = @request.script_name
              sc = "/" if sc.size == 0
              regexp = Regexp.new( self.class.__urls__[0][sc] )
              nargs = self.class.__urls__[1]
              
              args = regexp.match( Rack::Utils.unescape(@request.path_info).gsub( /^\//, "" ) )
              if args.nil?
                raise Capcode::ParameterError, "Path info `#{@request.path_info}' does not match route regexp `#{regexp.source}'"
              else
                args = args.captures
              end
              
              while args.size < nargs
                args << nil
              end
                                          
              get( *args )
            when "POST"
              post
          end
          if r.respond_to?(:to_ary)
            @response.status = r[0]
            r[1].each do |k,v|
              @response[k] = v
            end
            @response.body = r[2]
          else
            @response.write r
          end
          
          @response.finish
        end
                
        include Capcode::Helpers
      }      
    end
  
    def map( r, &b )
      @@__ROUTES[r] = yield
    end
  
    def run( args = {} )
      conf = {
        :port => args[:port]||3000, 
        :host => args[:host]||"localhost",
        :server => args[:server]||nil,
        :log => args[:log]||$stdout,
        :session => args[:session]||{}
      }
      
      if conf[:server].nil? || conf[:server] == "mongrel"
        begin
          require 'mongrel'
          conf[:server] = "mongrel"
        rescue LoadError 
          puts "!! could not load mongrel. Falling back to webrick."
          conf[:server] = "webrick"
        end
      end
          
      Capcode.constants.each do |k|
        begin
          if eval "Capcode::#{k}.public_methods(true).include?( '__urls__' )"
            u, m, c = eval "Capcode::#{k}.__urls__"
            u.keys.each do |_u|
              @@__ROUTES[_u] = c.new
            end
          end
        rescue => e
          raise e.message
        end
      end
      
      app = Rack::URLMap.new(@@__ROUTES)
      app = Rack::Session::Cookie.new( app, conf[:session] )
      app = Capcode::HTTPError.new(app)
      app = Rack::ContentLength.new(app)
      app = Rack::Lint.new(app)
      app = Rack::ShowExceptions.new(app)
      app = Rack::CommonLogger.new( app, Logger.new(conf[:log]) )
      
      
      case conf[:server]
      when "mongrel"
        puts "** Starting Mongrel on #{conf[:host]}:#{conf[:port]}"
        Rack::Handler::Mongrel.run( app, {:Port => conf[:port], :Host => conf[:host]} ) { |server|
          trap "SIGINT", proc { server.stop }
        }
      when "webrick"
        puts "** Starting WEBrick on #{conf[:host]}:#{conf[:port]}"
        Rack::Handler::WEBrick.run( app, {:Port => conf[:port], :BindAddress => conf[:host]} ) { |server|
          trap "SIGINT", proc { server.shutdown }
        }
      end
    end

    def routes
      @@__ROUTES
    end
  end
end

Bon, je vous avais promis un blog... Voici donc un blog :

$:.unshift( "../lib" )
require 'rubygems'
require 'capcode'
require 'couch_foo'

CouchFoo::Base.set_database(:host => "http://localhost:5984", :database => "my_blog")

class Story < CouchFoo::Base
  property :title, String
  property :body, String
  property :date, String
end

module Capcode
  class HTTPError
    def r404(f)
      "Pas glop !!! #{f} est inconnu !!!"
    end
  end
  
  class Index < Route '/'
    def get
      r = "<html><body>"
      
      story = Story.find( :all )
      
      story.each do |s|
        r += "<h2>#{s.title}</h2><small>#{s.date} - <a href='#{URL( Remove, s.id, s.rev )}'>Delete this entry</a></small><p>#{s.body}</p>"
      end
      
      r+"<hr /><a href='#{URL(Add)}'>Add a new entry</a></body></html>"
    end
  end
  
  class Remove < Route '/remove/([^\/]*)/(.*)'
    def get( id, rev )
      Story.delete(id, rev)
      redirect( Index )
    end
  end
  
  class Add < Route '/add'
    def get
      '
        <html><body>
          <h1>Add a new entry</h1>
          <form method="POST">
            Titre : <input type="text" name="title"><br />
            <textarea name="body"></textarea><br />
            <input type="submit">
          </form>
        </body></html>
      '
    end
    def post
      Story.create( :title => params['title'], :body => params['body'], :date => Time.now.to_s )
      redirect( Index )
    end
  end
end

Capcode.run( :port => 3001, :host => "localhost", :log => "blog.log" )

A ben oui ! Avec CouchDB et couch_foo tout de vient plus simple ;)

Nous en avons terminé avec cette présentation de Rack. Je vais encore faire quelques améliorations dans Capcode, et ensuite je le mettrai à disposition. La prochaine fois que nous en parlerons, cela sera pour développer avec Cappuccino.

1 "à croire que certains seraient tentés d'en faire leur métier !" -- Stephane S. in Je sers la science et c'est ma joie
2 Le blog c'est un peu le hello world des framework Web finalement !?
3 Je peux proposer tout ce que je veux, de toute façon vous ne pouvez pas répondre ...
4 Sinon pourquoi s'embêter à mettre des règles ?
5 Je vous avais bien dit "comme un contrôleur" !!!
6 Encore !!!

Copyright © 2009 - 2011 Grégoire Lejeune.
All documents licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 2.5 License, except ones with specified licence.
Powered by Jekyll.