From 64a07f8d3e23fed49220b99fc619107812b8ec9d Mon Sep 17 00:00:00 2001 From: Sven Fuchs Date: Fri, 30 May 2008 01:09:09 +0200 Subject: [PATCH] initial commit --- MIT-LICENSE | 20 ++++++ README.markdown | 116 ++++++++++++++++++++++++++++++ init.rb | 1 + lib/routing_filter.rb | 82 +++++++++++++++++++++ lib/routing_filter/base.rb | 9 +++ lib/routing_filter/locale.rb | 23 ++++++ spec/root_section.rb | 41 +++++++++++ spec/routing_filter_spec.rb | 136 +++++++++++++++++++++++++++++++++++ spec/spec.opts | 4 ++ spec/spec_helper.rb | 18 +++++ 10 files changed, 450 insertions(+) create mode 100644 MIT-LICENSE create mode 100644 README.markdown create mode 100644 init.rb create mode 100644 lib/routing_filter.rb create mode 100644 lib/routing_filter/base.rb create mode 100644 lib/routing_filter/locale.rb create mode 100644 spec/root_section.rb create mode 100644 spec/routing_filter_spec.rb create mode 100644 spec/spec.opts create mode 100644 spec/spec_helper.rb diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..ac93a58 --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2008 Sven Fuchs + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..695e881 --- /dev/null +++ b/README.markdown @@ -0,0 +1,116 @@ +## Routing Filter + +This plugin is a wild hack that wraps around the complex beast that the Rails +routing system is to allow for unseen flexibility and power in Rails URL +recognition and generation. + +As powerful and awesome the Rails' routes are, when you need to design your +URLs in a manner that only slightly leaves the paved cowpaths of Rails +conventions, you're usually unable to use all the goodness of helpers and +convenience that Rails ships with. + +## Usage + +This plugin comes with a locale routing filter that demonstrates the +implementation of a custom filter. + +The following would be a sceleton of an empty filter: + + module RoutingFilter + class Awesomeness < Base + def around_recognition(route, path, env) + # Alter the path here before it gets recognized. + # Next make sure to yield (calls the next around filter if present + # and eventually `recognize_path` on the routeset): + returning yield(path, env) do |params| + # You can additionally modify the params here before they get passed + # to the controller. + end + end + + def after_generate(base, url_or_path, *args) + # You can change the generated url_or_path here. Make sure to use + # one of the "in-place" modifying String methods though (like sub! and + # friends). + end + end + end + +You then have to specify the filter explicitely in your routes.rb: + + ActionController::Routing::Routes.draw do |map| + map.filter 'awesomeness' + end + +(I am not sure if it makes sense to provide more technical information than +this because the usage of this plugin definitely requires some advanced +knowledge about Rails internals and especially its routing system. So, I +figure, anyone who could use this should also be able to read the code and +figure out what it's doing much better then from any lengthy documentation. + +If I'm mistaken on this please drop me an email with your suggestions.) + + +## Rationale: Two example usecases + +An early usecase from which this originated was the need to define a locale +at the beginning of an URL in a way so that + +* the locale can be omitted when it is the default locale +* all the url\_helpers that are generated by named routes continue to work in +a concise manner (i.e. without specifying all parameters again and again) +* ideally also plays nicely with default route helpers in tests/specs + +You can read about this struggle and to possible, yet unsatisfying solutions +[here](). The conclusion so far is that Rails itself does not provide the +tools to solve this problem in a clean and dry way. + +Another usecase that eventually spawned the manifestation of this plugin was +the need to map an arbitrary count of path segments to a certain model +instance. In an application that I've been working on recently I needed to +map URL paths to a nested tree of models like so: + + root + + docs + + api + + wiki + +E.g. the docs section should map to the path /docs, the api section to +the path /docs/api and so on. Furthermore, after these paths need to be +more things to be specified. E.g. the wiki needs define a usual Rails resource +like /docs/wiki/pages/1/edit. + +The only way to solve this problem with Rails' routing toolkit is to map +a bold /*whatever catch-all ("glob") and process the whole path in a custom +dispatcher. + +This, of course, is a really unsatisfying solution because one has to +reimplement everything that Rails routes are here to help with, both with +regards to URL recognition (like parameter mappings, resources, ...) and +generation (url\_helpers don't work). + +## Solution + +This plugin offers a solution that takes exactly the opposite route. Instead +of trying to change things *between* the URL recognition and generation stages +to achieve the desired result it *wraps around* the whole routing system and +allows to filter both what goes into it (URL recognition) and what comes out +of it (URL generation). + +This way we can leave *everything* else completely untouched. + +* We can tinker with the URLs that we receive from the server and feed URLs to +Rails that perfectly match the best breed of Rails' conventions. +* Inside of the application we can use all the nice helper goodness and +conveniences that rely on these conventions being followed. +* Finally we can accept URLs that have been generated by the url\_helpers and, +again, mutate them in the way that matches our requirements. + +So, even though the plugin itself is a blatant monkey-patch to one of the +most complex area of Rails internals, this solution seems to be effectively +less intrusive and pricey than others are. + +## Etc + +Authors: [Sven Fuchs](http://www.artweb-design.de) +License: MIT \ No newline at end of file diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..1189921 --- /dev/null +++ b/init.rb @@ -0,0 +1 @@ +require 'routing_filter' \ No newline at end of file diff --git a/lib/routing_filter.rb b/lib/routing_filter.rb new file mode 100644 index 0000000..97ec066 --- /dev/null +++ b/lib/routing_filter.rb @@ -0,0 +1,82 @@ +module RoutingFilter; end # make dependencies happy + +# allows to install a filter to the route set by calling: map.filter 'locale' +ActionController::Routing::RouteSet::Mapper.class_eval do + def filter(name, options = {}) + klass = "RoutingFilter::#{name.to_s.camelize}".constantize + @set.filters ||= [] + @set.filters.push klass.new(options) + end +end + +# hook into url_for and call before and after filters +ActionController::Base.class_eval do + def url_for_with_filtering(options = nil) + ActionController::Routing::Routes.filter_generate self, :before, options + returning url_for_without_filtering(options) do |result| + ActionController::Routing::Routes.filter_generate self, :after, result, options + end + end + alias_method_chain :url_for, :filtering +end + +# same here for the optimized url generation in named routes +ActionController::Routing::RouteSet::NamedRouteCollection.class_eval do + # gosh. monkey engineering optimization code + def generate_optimisation_block_with_filtering(*args) + code = generate_optimisation_block_without_filtering *args + if match = code.match(%r(^return (.*) if (.*))) + <<-code + if #{match[2]} + ActionController::Routing::Routes.filter_generate self, :before, *args + result = #{match[1]} + ActionController::Routing::Routes.filter_generate self, :after, result, *args + return result + end + code + end + end + alias_method_chain :generate_optimisation_block, :filtering +end + +ActionController::Routing::RouteSet.class_eval do + # allow to register filters to the route set + def filters + @filters ||= [] + end + + # call filter stage (:before or :after) with the passed args + def filter_generate(base, stage, *args) + filters.each do |filter| + filter.send :"#{stage}_generate", base, *args if filter.respond_to? :"#{stage}_generate" + end + end + + # wrap recognition filters around recognize_path + def recognize_path_with_filtering(path, env) + path = path.dup + chain = [lambda{|path, env| recognize_path_without_filtering(path, env) }] + filters.each do |filter| + chain.unshift lambda{|path, env| + filter.around_recognition(self, path, env, &chain.shift) + } + end + chain.shift.call path, env + end + alias_method_chain :recognize_path, :filtering + + # add some useful information to the request environment + # right, this is from jamis buck's excellent article about routes internals + # http://weblog.jamisbuck.org/2006/10/26/monkey-patching-rails-extending-routes-2 + # TODO move this ... where? + alias_method :extract_request_environment_without_host, :extract_request_environment unless method_defined? :extract_request_environment_without_host + def extract_request_environment(request) + returning extract_request_environment_without_host(request) do |env| + env.merge! :host => request.host, + :port => request.port, + :host_with_port => request.host_with_port, + :domain => request.domain, + :subdomain => request.subdomains.first + end + end +end \ No newline at end of file diff --git a/lib/routing_filter/base.rb b/lib/routing_filter/base.rb new file mode 100644 index 0000000..06ad4a2 --- /dev/null +++ b/lib/routing_filter/base.rb @@ -0,0 +1,9 @@ +module RoutingFilter + class Base + attr_reader :options + + def initialize(options) + @options = options + end + end +end \ No newline at end of file diff --git a/lib/routing_filter/locale.rb b/lib/routing_filter/locale.rb new file mode 100644 index 0000000..c4dcd7a --- /dev/null +++ b/lib/routing_filter/locale.rb @@ -0,0 +1,23 @@ +module RoutingFilter + class Locale < Base + @@default_locale = 'en' + cattr_reader :default_locale + + # remove the locale from the beginning of the path, pass the path + # to the given block and set it to the resulting params hash + def around_recognition(route, path, env, &block) + locale = nil + path.sub! %r(^/([a-zA-Z]{2})(?=/|$)) do locale = $1; '' end + returning yield(path, env) do |params| + params[:locale] = locale if locale + end + end + + # prepend the current locale to the path if it's not the default locale + def after_generate(base, result, *args) + locale = base.instance_variable_get(:@locale) + result.replace "/#{locale}#{result}" if locale and locale != @@default_locale + # TODO won't work with full urls, stupid + end + end +end \ No newline at end of file diff --git a/spec/root_section.rb b/spec/root_section.rb new file mode 100644 index 0000000..a4d8be3 --- /dev/null +++ b/spec/root_section.rb @@ -0,0 +1,41 @@ +# only here for test purposes ... + +module RoutingFilter + class RootSection < Base + + # this pattern matches a path that starts (aside from an optional locale) + # with a single slash or one of articles|pages|categories|tags, 4 digits + # or a dot followed by anything. + # + # So all of the following paths will match: + # / and /de + # /articles and /de/articles (same with pages, categories, tags) + # /2008 and /de/2008 + # /.rss and /de.rss + + # TODO ... should be defined through the dsl in routes.rb + @@pattern = %r(^/?(/[\w]{2})?(/articles|/pages|/categories|/tags|/\d{4}|\.|/?$)) + + # prepends the root section path to the path if the given pattern matches + def around_recognition(route, path, env, &block) + unless path =~ %r(^/admin) # TODO ... should be defined through the dsl in routes.rb + if match = path.match(@@pattern) + section = Site.find_by_host(env[:host_with_port]).sections.root + path.sub! /^#{match[0]}/, "#{match[1]}/#{section.type.pluralize.downcase}/#{section.id}#{match[2]}" + end + end + yield path, env + end + + def after_generate(base, result, *args) + if site = base.instance_variable_get(:@site) + root = site.sections.root + pattern = %r(^(/[\w]{2})?(/#{root.type.pluralize.downcase}/#{root.id}(\.|/|$))) + if match = result.match(pattern) + result.sub! match[2], match[3] unless match[3] == '.' + result.replace '/' if result.empty? + end + end + end + end +end diff --git a/spec/routing_filter_spec.rb b/spec/routing_filter_spec.rb new file mode 100644 index 0000000..cdb54b1 --- /dev/null +++ b/spec/routing_filter_spec.rb @@ -0,0 +1,136 @@ +require File.dirname(__FILE__) + '/spec_helper.rb' + +Routing = ActionController::Routing + +describe 'RoutingFilter' do + before :each do + @controller = instantiate_controller :locale => 'de', :section_id => 1 + end + + def draw_routes(&block) + set = returning Routing::RouteSet.new do |set| + class << set; def clear!; end; end + set.draw &block + silence_warnings{ Routing.const_set 'Routes', set } + end + set + end + + def instantiate_controller(params) + returning ActionController::Base.new do |controller| + request = ActionController::TestRequest.new + url = ActionController::UrlRewriter.new(request, params) + controller.stub!(:request).and_return request + controller.instance_variable_set :@url, url + controller + end + end + + describe 'basics' do + before :each do + @set = draw_routes do |map| + map.section 'sections/:section_id', :controller => 'sections', :action => "show" + map.filter 'locale' + map.filter 'root_section' + end + + @locale_filter = @set.filters.first + @root_section_filter = @set.filters.last + end + + it 'installs a filter to the route set' do + @locale_filter.should be_instance_of(RoutingFilter::Locale) + end + + it 'calls the first filter for route recognition' do + @locale_filter.should_receive(:around_recognition).and_return {} + @set.recognize_path '/de/sections/1', {} + end + + it 'calls the second filter for route recognition' do + @root_section_filter.should_receive(:around_recognition).and_return {} + @set.recognize_path '/de/sections/1', {} + end + + it 'calls the filter for url_for' do + @locale_filter.should_receive :after_generate + @controller.send :url_for, :controller => 'sections', :action => 'show', :section_id => 1 + end + + it 'calls the filter for named route url_helper' do + @locale_filter.should_receive :after_generate + @controller.send :section_path, :section_id => 1 + end + + it 'calls the filter for named route url_helper with "optimized" generation blocks' do + @locale_filter.should_receive :after_generate + @controller.send :section_path, 1 + end + + it 'calls the filter for named route polymorphic_path' do + @locale_filter.should_receive :after_generate + @controller.send :section_path, Section.new + end + end + + describe 'the locale filter' do + before :each do + @set = draw_routes do |map| + map.section 'sections/:section_id', :controller => 'sections', :action => "show" + map.filter 'locale' + end + + @locale_filter = @set.filters.first + @root_section_filter = @set.filters.last + end + + it 'recognizes the path /de/sections/1 and sets the :locale param' do + @set.recognize_path('/de/sections/1', {})[:locale].should == 'de' + end + + it 'recognizes the path /sections/1 and does not set a :locale param' do + @set.recognize_path('/sections/1', {})[:locale].should be_nil + end + + it 'with the default locale set does not change a generated path' do + @controller.instance_variable_set :@locale, 'en' + @controller.send(:section_path, :section_id => 1).should == '/sections/1' + end + + it 'with a non-default locale appends it to the generated path' do + @controller.instance_variable_set :@locale, 'de' + @controller.send(:section_path, :section_id => 1).should == '/de/sections/1' + end + + it 'with no locale present does not change a generated path' do + @controller.instance_variable_set :@locale, nil + @controller.send(:section_path, :section_id => 1).should == '/sections/1' + end + end +end + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/spec.opts b/spec/spec.opts new file mode 100644 index 0000000..391705b --- /dev/null +++ b/spec/spec.opts @@ -0,0 +1,4 @@ +--colour +--format progress +--loadby mtime +--reverse diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..003c4ac --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,18 @@ +$LOAD_PATH.unshift File.dirname(__FILE__) + '/../../../rails/actionpack/lib/' +require 'action_controller' +require 'action_controller/test_process' + +$LOAD_PATH << File.dirname(__FILE__) + '/../lib/' +require 'routing_filter' +require 'routing_filter/base' +require 'routing_filter/locale' +require File.dirname(__FILE__) + '/root_section.rb' + +class Section + def self.types + [] + end + def to_param + 1 + end +end