Skip to content

Commit 419c715

Browse files
committed
Add support for defining custom url helpers in routes.rb
Allow the definition of custom url helpers that will be available automatically wherever standard url helpers are available. The current solution is to create helper methods in ApplicationHelper or some other helper module and this isn't a great solution since the url helper module can be called directly or included in another class which doesn't include the normal helper modules. Reference rails#22512.
1 parent 55bdfbe commit 419c715

File tree

4 files changed

+270
-2
lines changed

4 files changed

+270
-2
lines changed

actionpack/lib/action_dispatch/routing/mapper.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,46 @@ def concerns(*args)
20152015
end
20162016
end
20172017

2018+
module UrlHelpers
2019+
# Define a custom url helper that will be added to the url helpers
2020+
# module. This allows you override and/or replace the default behavior
2021+
# of routing helpers, e.g:
2022+
#
2023+
# url_helper :homepage do
2024+
# "http://www.rubyonrails.org"
2025+
# end
2026+
#
2027+
# url_helper :commentable do |model|
2028+
# [ model, anchor: model.dom_id ]
2029+
# end
2030+
#
2031+
# url_helper :main do
2032+
# { controller: 'pages', action: 'index', subdomain: 'www' }
2033+
# end
2034+
#
2035+
# The return value must be a valid set of arguments for `url_for` which
2036+
# will actually build the url string. This can be one of the following:
2037+
#
2038+
# * A string, which is treated as a generated url
2039+
# * A hash, e.g. { controller: 'pages', action: 'index' }
2040+
# * An array, which is passed to `polymorphic_url`
2041+
# * An Active Model instance
2042+
# * An Active Model class
2043+
#
2044+
# You can also specify default options that will be passed through to
2045+
# your url helper definition, e.g:
2046+
#
2047+
# url_helper :browse, page: 1, size: 10 do |options|
2048+
# [ :products, options.merge(params.permit(:page, :size)) ]
2049+
# end
2050+
#
2051+
# NOTE: It is the url helper's responsibility to return the correct
2052+
# set of options to be passed to the `url_for` call.
2053+
def url_helper(name, options = {}, &block)
2054+
@set.add_url_helper(name, options, &block)
2055+
end
2056+
end
2057+
20182058
class Scope # :nodoc:
20192059
OPTIONS = [:path, :shallow_path, :as, :shallow_prefix, :module,
20202060
:controller, :action, :path_names, :constraints,
@@ -2109,6 +2149,7 @@ def initialize(set) #:nodoc:
21092149
include Scoping
21102150
include Concerns
21112151
include Resources
2152+
include UrlHelpers
21122153
end
21132154
end
21142155
end

actionpack/lib/action_dispatch/routing/route_set.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def initialize
7474
@routes = {}
7575
@path_helpers = Set.new
7676
@url_helpers = Set.new
77+
@custom_helpers = Set.new
7778
@url_helpers_module = Module.new
7879
@path_helpers_module = Module.new
7980
end
@@ -96,9 +97,23 @@ def clear!
9697
@url_helpers_module.send :undef_method, helper
9798
end
9899

100+
@custom_helpers.each do |helper|
101+
path_name = :"#{helper}_path"
102+
url_name = :"#{helper}_url"
103+
104+
if @path_helpers_module.method_defined?(path_name)
105+
@path_helpers_module.send :undef_method, path_name
106+
end
107+
108+
if @url_helpers_module.method_defined?(url_name)
109+
@url_helpers_module.send :undef_method, url_name
110+
end
111+
end
112+
99113
@routes.clear
100114
@path_helpers.clear
101115
@url_helpers.clear
116+
@custom_helpers.clear
102117
end
103118

104119
def add(name, route)
@@ -144,6 +159,62 @@ def length
144159
routes.length
145160
end
146161

162+
def add_url_helper(name, defaults, &block)
163+
@custom_helpers << name
164+
helper = CustomUrlHelper.new(name, defaults, &block)
165+
166+
@path_helpers_module.module_eval do
167+
define_method(:"#{name}_path") do |*args|
168+
options = args.extract_options!
169+
helper.call(self, args, options, only_path: true)
170+
end
171+
end
172+
173+
@url_helpers_module.module_eval do
174+
define_method(:"#{name}_url") do |*args|
175+
options = args.extract_options!
176+
helper.call(self, args, options)
177+
end
178+
end
179+
end
180+
181+
class CustomUrlHelper
182+
attr_reader :name, :defaults, :block
183+
184+
def initialize(name, defaults, &block)
185+
@name = name
186+
@defaults = defaults
187+
@block = block
188+
end
189+
190+
def call(t, args, options, outer_options = {})
191+
url_options = eval_block(t, args, options)
192+
193+
case url_options
194+
when String
195+
t.url_for(url_options)
196+
when Hash
197+
t.url_for(url_options.merge(outer_options))
198+
when ActionController::Parameters
199+
if url_options.permitted?
200+
t.url_for(url_options.to_h.merge(outer_options))
201+
else
202+
raise ArgumentError, "Generating an URL from non sanitized request parameters is insecure!"
203+
end
204+
when Array
205+
opts = url_options.extract_options!
206+
t.url_for(url_options.push(opts.merge(outer_options)))
207+
else
208+
t.url_for([url_options, outer_options])
209+
end
210+
end
211+
212+
private
213+
def eval_block(t, args, options)
214+
t.instance_exec(*args, defaults.merge(options), &block)
215+
end
216+
end
217+
147218
class UrlHelper
148219
def self.create(route, options, route_name, url_strategy)
149220
if optimize_helper?(route)
@@ -533,6 +604,10 @@ def add_route(mapping, path_ast, name, anchor)
533604
route
534605
end
535606

607+
def add_url_helper(name, options, &block)
608+
named_routes.add_url_helper(name, options, &block)
609+
end
610+
536611
class Generator
537612
PARAMETERIZE = lambda do |name, value|
538613
if name == :controller
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
require 'abstract_unit'
2+
3+
class TestCustomUrlHelpers < ActionDispatch::IntegrationTest
4+
class Linkable
5+
attr_reader :id
6+
7+
def initialize(id)
8+
@id = id
9+
end
10+
11+
def linkable_type
12+
self.class.name.demodulize.underscore
13+
end
14+
end
15+
16+
class Category < Linkable; end
17+
class Collection < Linkable; end
18+
class Product < Linkable; end
19+
20+
Routes = ActionDispatch::Routing::RouteSet.new
21+
Routes.draw do
22+
default_url_options host: 'www.example.com'
23+
24+
root to: 'pages#index'
25+
get '/basket', to: 'basket#show', as: :basket
26+
27+
resources :categories, :collections, :products
28+
29+
namespace :admin do
30+
get '/dashboard', to: 'dashboard#index'
31+
end
32+
33+
url_helper(:website) { "http://www.rubyonrails.org" }
34+
url_helper(:linkable) { |linkable| [:"#{linkable.linkable_type}", { id: linkable.id }] }
35+
url_helper(:params) { |params| params }
36+
url_helper(:symbol) { :basket }
37+
url_helper(:hash) { { controller: "basket", action: "show" } }
38+
url_helper(:array) { [:admin, :dashboard] }
39+
url_helper(:options) { |options| [:products, options] }
40+
url_helper(:defaults, size: 10) { |options| [:products, options] }
41+
end
42+
43+
APP = build_app Routes
44+
45+
def app
46+
APP
47+
end
48+
49+
include Routes.url_helpers
50+
51+
def setup
52+
@category = Category.new("1")
53+
@collection = Collection.new("2")
54+
@product = Product.new("3")
55+
@path_params = { 'controller' => 'pages', 'action' => 'index' }
56+
@unsafe_params = ActionController::Parameters.new(@path_params)
57+
@safe_params = ActionController::Parameters.new(@path_params).permit(:controller, :action)
58+
end
59+
60+
def test_custom_path_helper
61+
assert_equal "http://www.rubyonrails.org", website_path
62+
assert_equal "http://www.rubyonrails.org", Routes.url_helpers.website_path
63+
64+
assert_equal "/categories/1", linkable_path(@category)
65+
assert_equal "/categories/1", Routes.url_helpers.linkable_path(@category)
66+
assert_equal "/collections/2", linkable_path(@collection)
67+
assert_equal "/collections/2", Routes.url_helpers.linkable_path(@collection)
68+
assert_equal "/products/3", linkable_path(@product)
69+
assert_equal "/products/3", Routes.url_helpers.linkable_path(@product)
70+
71+
assert_equal "/", params_path(@safe_params)
72+
assert_equal "/", Routes.url_helpers.params_path(@safe_params)
73+
assert_raises(ArgumentError) { params_path(@unsafe_params) }
74+
assert_raises(ArgumentError) { Routes.url_helpers.params_path(@unsafe_params) }
75+
76+
assert_equal "/basket", symbol_path
77+
assert_equal "/basket", Routes.url_helpers.symbol_path
78+
assert_equal "/basket", hash_path
79+
assert_equal "/basket", Routes.url_helpers.hash_path
80+
assert_equal "/admin/dashboard", array_path
81+
assert_equal "/admin/dashboard", Routes.url_helpers.array_path
82+
83+
assert_equal "/products?page=2", options_path(page: 2)
84+
assert_equal "/products?page=2", Routes.url_helpers.options_path(page: 2)
85+
assert_equal "/products?size=10", defaults_path
86+
assert_equal "/products?size=10", Routes.url_helpers.defaults_path
87+
assert_equal "/products?size=20", defaults_path(size: 20)
88+
assert_equal "/products?size=20", Routes.url_helpers.defaults_path(size: 20)
89+
end
90+
91+
def test_custom_url_helper
92+
assert_equal "http://www.rubyonrails.org", website_url
93+
assert_equal "http://www.rubyonrails.org", Routes.url_helpers.website_url
94+
95+
assert_equal "http://www.example.com/categories/1", linkable_url(@category)
96+
assert_equal "http://www.example.com/categories/1", Routes.url_helpers.linkable_url(@category)
97+
assert_equal "http://www.example.com/collections/2", linkable_url(@collection)
98+
assert_equal "http://www.example.com/collections/2", Routes.url_helpers.linkable_url(@collection)
99+
assert_equal "http://www.example.com/products/3", linkable_url(@product)
100+
assert_equal "http://www.example.com/products/3", Routes.url_helpers.linkable_url(@product)
101+
102+
assert_equal "http://www.example.com/", params_url(@safe_params)
103+
assert_equal "http://www.example.com/", Routes.url_helpers.params_url(@safe_params)
104+
assert_raises(ArgumentError) { params_url(@unsafe_params) }
105+
assert_raises(ArgumentError) { Routes.url_helpers.params_url(@unsafe_params) }
106+
107+
assert_equal "http://www.example.com/basket", symbol_url
108+
assert_equal "http://www.example.com/basket", Routes.url_helpers.symbol_url
109+
assert_equal "http://www.example.com/basket", hash_url
110+
assert_equal "http://www.example.com/basket", Routes.url_helpers.hash_url
111+
assert_equal "/admin/dashboard", array_path
112+
assert_equal "/admin/dashboard", Routes.url_helpers.array_path
113+
114+
assert_equal "http://www.example.com/products?page=2", options_url(page: 2)
115+
assert_equal "http://www.example.com/products?page=2", Routes.url_helpers.options_url(page: 2)
116+
assert_equal "http://www.example.com/products?size=10", defaults_url
117+
assert_equal "http://www.example.com/products?size=10", Routes.url_helpers.defaults_url
118+
assert_equal "http://www.example.com/products?size=20", defaults_url(size: 20)
119+
assert_equal "http://www.example.com/products?size=20", Routes.url_helpers.defaults_url(size: 20)
120+
end
121+
end

railties/test/application/routing_test.rb

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,10 @@ def index
245245
assert_equal 'WIN', last_response.body
246246
end
247247

248-
{"development" => "baz", "production" => "bar"}.each do |mode, expected|
248+
{
249+
"development" => ["baz", "http://www.apple.com"],
250+
"production" => ["bar", "http://www.microsoft.com" ]
251+
}.each do |mode, (expected_action, expected_url)|
249252
test "reloads routes when configuration is changed in #{mode}" do
250253
controller :foo, <<-RUBY
251254
class FooController < ApplicationController
@@ -256,12 +259,19 @@ def bar
256259
def baz
257260
render text: "baz"
258261
end
262+
263+
def custom
264+
render text: custom_url
265+
end
259266
end
260267
RUBY
261268

262269
app_file 'config/routes.rb', <<-RUBY
263270
Rails.application.routes.draw do
264271
get 'foo', to: 'foo#bar'
272+
get 'custom', to: 'foo#custom'
273+
274+
url_helper(:custom) { "http://www.microsoft.com" }
265275
end
266276
RUBY
267277

@@ -270,16 +280,25 @@ def baz
270280
get '/foo'
271281
assert_equal 'bar', last_response.body
272282

283+
get '/custom'
284+
assert_equal 'http://www.microsoft.com', last_response.body
285+
273286
app_file 'config/routes.rb', <<-RUBY
274287
Rails.application.routes.draw do
275288
get 'foo', to: 'foo#baz'
289+
get 'custom', to: 'foo#custom'
290+
291+
url_helper(:custom) { "http://www.apple.com" }
276292
end
277293
RUBY
278294

279295
sleep 0.1
280296

281297
get '/foo'
282-
assert_equal expected, last_response.body
298+
assert_equal expected_action, last_response.body
299+
300+
get '/custom'
301+
assert_equal expected_url, last_response.body
283302
end
284303
end
285304

@@ -340,6 +359,10 @@ class FooController < ApplicationController
340359
def index
341360
render text: "foo"
342361
end
362+
363+
def custom
364+
render text: custom_url
365+
end
343366
end
344367
RUBY
345368

@@ -425,16 +448,19 @@ def index
425448
app_file 'config/routes.rb', <<-RUBY
426449
Rails.application.routes.draw do
427450
get ':locale/foo', to: 'foo#index', as: 'foo'
451+
url_helper(:microsoft) { 'http://www.microsoft.com' }
428452
end
429453
RUBY
430454

431455
get '/en/foo'
432456
assert_equal 'foo', last_response.body
433457
assert_equal '/en/foo', Rails.application.routes.url_helpers.foo_path(:locale => 'en')
458+
assert_equal 'http://www.microsoft.com', Rails.application.routes.url_helpers.microsoft_url
434459

435460
app_file 'config/routes.rb', <<-RUBY
436461
Rails.application.routes.draw do
437462
get ':locale/bar', to: 'bar#index', as: 'foo'
463+
url_helper(:apple) { 'http://www.apple.com' }
438464
end
439465
RUBY
440466

@@ -446,6 +472,11 @@ def index
446472
get '/en/bar'
447473
assert_equal 'bar', last_response.body
448474
assert_equal '/en/bar', Rails.application.routes.url_helpers.foo_path(:locale => 'en')
475+
assert_equal 'http://www.apple.com', Rails.application.routes.url_helpers.apple_url
476+
477+
assert_raises NoMethodError do
478+
assert_equal 'http://www.microsoft.com', Rails.application.routes.url_helpers.microsoft_url
479+
end
449480
end
450481

451482
test 'resource routing with irregular inflection' do

0 commit comments

Comments
 (0)