jtrupiano

Rack::Rewrite

Submitted by jtrupiano at 12 Oct 21:44
Hide details

About

rack-rewrite

A rack middleware for defining and applying rewrite rules. In many cases you can get away with rack-rewrite instead of writing Apache mod_rewrite rules.

Rack::Rewrite is available as a gem: gem install rack-rewrite
Full source code is at github: http://github.com/jtrupiano/rack-rewrite

Use Cases

Rebuild of existing site in a new technology

It’s very common for sites built in older technologies to be rebuilt with the latest and greatest. Let’s consider a site that has already established quite a bit of “google juice.” When we launch the new site, we don’t want to lose that hard-earned reputation. By writing rewrite rules that issue 301’s for old URL’s, we can transfer that google ranking to the new site. An example rule might look like:

  r301 '/contact-us.php', '/contact-us'
  r301 '/wiki/John_Trupiano', '/john'

Retiring old routes

As a web application evolves you will sometimes have to make changes to routes. The danger here is that any URL’s previously generated (in a transactional email for instance) will have the URL hard-coded. In order for your rails app to continue to serve this URL, you’ll need to add an extra entry to your routes file. Alternatively, you could use Rack::Rewrite to redirect or pass through requests to these routes and keep your routes.rb clean.

  rewrite %r{/features(.*)}, '/facial_features$1'

Site maintenance

This common capistrano + apache ruleset

  RewriteCond %{REQUEST_URI} !\.(css|jpg|png)$
  RewriteCond %{DOCUMENT_ROOT}/system/maintenance.html -f
  RewriteCond %{SCRIPT_FILENAME} !maintenance.html
  RewriteRule ^.*$ /system/maintenance.html [L]

can be replaced by Rack::Rewrite with the following rule

  maintenance_file = File.join(RAILS_ROOT, 'public', 'system', 'maintenance.html')

  # Ruby 1.9.x (w/ regexp negative lookahead)
  send_file /(.*)$(?<!css|png|jpg)/, maintenance_file, :if => Proc.new { |rack_env|
    File.exists?(maintenance_file)
  }

  # Vanilla Ruby 1.8.x
  send_file /.*/, maintenance_file, :if => Proc.new { |rack_env|
    File.exists?(maintenance_file) && rack_env['REQUEST_URI'] !~ /\.(css|jpg|png)/
  }

Usage

Sample usage in a rackup file

  gem 'rack-rewrite', '~> 0.2.0'
  require 'rack-rewrite
  use Rack::Rewrite do
    rewrite '/wiki/John_Trupiano', '/john'
    r301 '/wiki/Yair_Flicker', '/yair'
    r302 '/wiki/Greg_Jastrab', '/greg'
    r301 %r{/wiki/(\w+)_\w+}, '/$1'
  end

Sample usage in a rails app

  config.gem 'rack-rewrite', '~> 0.2.0'
  require 'rack-rewrite
  config.middleware.insert_before(Rack::Lock, Rack::Rewrite) do
    rewrite '/wiki/John_Trupiano', '/john'
    r301 '/wiki/Yair_Flicker', '/yair'
    r302 '/wiki/Greg_Jastrab', '/greg'
    r301 %r{/wiki/(\w+)_\w+}, '/$1'
  end

Code

# This is actually available as a gem: gem install rack-rewrite
# Full source code including tests is on github: http://github.com/jtrupiano/rack-rewrite
 
module Rack
  # A rack middleware for defining and applying rewrite rules. In many cases you
  # can get away with rack-rewrite instead of writing Apache mod_rewrite rules.
  class Rewrite
    def initialize(app, &rule_block)
      @app = app
      @rule_set = RuleSet.new
      @rule_set.instance_eval(&rule_block) if block_given?
    end
    
    def call(env)
      if matched_rule = find_first_matching_rule(env)
        rack_response = matched_rule.apply!(env)
        # Don't invoke the app if applying the rule returns a rack response
        return rack_response unless rack_response === true
      end
      @app.call(env)
    end
        
    private
      def find_first_matching_rule(env) #:nodoc:
        @rule_set.rules.detect { |rule| rule.matches?(env) }
      end
 
    class RuleSet
      attr_reader :rules
      def initialize #:nodoc:
        @rules = []
      end
 
      protected
        # We're explicitly defining private functions for our DSL rather than
        # using method_missing
        
        # Creates a rewrite rule that will simply rewrite the REQUEST_URI,
        # PATH_INFO, and QUERYSTRING headers of the Rack environment. The
        # user's browser will continue to show the initially requested URL.
        #
        # rewrite '/wiki/John_Trupiano', '/john'
        # rewrite %r{/wiki/(\w+)_\w+}, '/$1'
        # rewrite %r{(.*)}, '/maintenance.html', :if => lambda { File.exists?('maintenance.html') }
        def rewrite(from, to, *args)
          options = args.last.is_a?(Hash) ? args.last : {}
          @rules << Rule.new(:rewrite, from, to, options[:if])
        end
        
        # Creates a redirect rule that will send a 301 when matching.
        #
        # r301 '/wiki/John_Trupiano', '/john'
        # r301 '/contact-us.php', '/contact-us'
        def r301(from, to, *args)
          options = args.last.is_a?(Hash) ? args.last : {}
          @rules << Rule.new(:r301, from, to, options[:if])
        end
        
        # Creates a redirect rule that will send a 302 when matching.
        #
        # r302 '/wiki/John_Trupiano', '/john'
        # r302 '/wiki/(.*)', 'http://www.google.com/?q=$1'
        def r302(from, to, *args)
          options = args.last.is_a?(Hash) ? args.last : {}
          @rules << Rule.new(:r302, from, to, options[:if])
        end
        
        # Creates a rule that will render a file if matched.
        #
        # send_file /*/, 'public/system/maintenance.html',
        # :if => Proc.new { File.exists?('public/system/maintenance.html') }
        def send_file(from, to, *args)
          options = args.last.is_a?(Hash) ? args.last : {}
          @rules << Rule.new(:send_file, from, to, options[:if])
        end
        
        # Creates a rule that will render a file using x-send-file
        # if matched.
        #
        # x_send_file /*/, 'public/system/maintenance.html',
        # :if => Proc.new { File.exists?('public/system/maintenance.html') }
        def x_send_file(from, to, *args)
          options = args.last.is_a?(Hash) ? args.last : {}
          @rules << Rule.new(:x_send_file, from, to, options[:if])
        end
    end
 
    # TODO: Break rules into subclasses
    class Rule #:nodoc:
      attr_reader :rule_type, :from, :to, :guard
      def initialize(rule_type, from, to, guard=nil) #:nodoc:
        @rule_type, @from, @to, @guard = rule_type, from, to, guard
      end
 
      def matches?(rack_env) #:nodoc:
        return false if !guard.nil? && !guard.call(rack_env)
        path = rack_env['REQUEST_URI']
        if self.from.is_a?(Regexp) || (Object.const_defined?(:Oniguruma) && self.from.is_a?(Oniguruma::ORegexp))
          path =~ self.from
        elsif self.from.is_a?(String)
          path == self.from
        else
          false
        end
      end
 
      # Either (a) return a Rack response (short-circuiting the Rack stack), or
      # (b) alter env as necessary and return true
      def apply!(env) #:nodoc:
        interpreted_to = self.send(:interpret_to, env['REQUEST_URI'], env)
        case self.rule_type
        when :r301
          [301, {'Location' => interpreted_to, 'Content-Type' => 'text/html'}, ['Redirecting...']]
        when :r302
          [302, {'Location' => interpreted_to, 'Content-Type' => 'text/html'}, ['Redirecting...']]
        when :rewrite
          # return [200, {}, {:content => env.inspect}]
          env['REQUEST_URI'] = interpreted_to
          if q_index = interpreted_to.index('?')
            env['PATH_INFO'] = interpreted_to[0..q_index-1]
            env['QUERYSTRING'] = interpreted_to[q_index+1..interpreted_to.size-1]
          else
            env['PATH_INFO'] = interpreted_to
            env['QUERYSTRING'] = ''
          end
          true
        when :send_file
          [200, {
            'Content-Length' => ::File.size(interpreted_to).to_s,
            'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to))
            }, ::File.read(interpreted_to)]
        when :x_send_file
          [200, {
            'X-Sendfile' => interpreted_to,
            'Content-Length' => ::File.size(interpreted_to).to_s,
            'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to))
            }, []]
        else
          raise Exception.new("Unsupported rule: #{self.rule_type}")
        end
      end
      
      private
        def interpret_to(path, env={}) #:nodoc:
          return interpret_to_proc(path, env) if self.to.is_a?(Proc)
          return computed_to(path) if compute_to?(path)
          self.to
        end
 
        def interpret_to_proc(path, env)
          return self.to.call(match(path), env) if self.from.is_a?(Regexp)
          self.to.call(self.from, env)
        end
 
        def compute_to?(path)
          self.from.is_a?(Regexp) && match(path)
        end
 
        def match(path)
          self.from.match(path)
        end
 
        def computed_to(path)
          # is there a better way to do this?
          computed_to = self.to.dup
          (match(path).size - 1).downto(1) do |num|
            computed_to.gsub!("$#{num}", match(path)[num])
          end
          return computed_to
        end
    end
  end
end
 
view raw gistfile1.rb This Gist brought to you by GitHub.
blog comments powered by Disqus