Skip to content

Commit 5e1c722

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 a624309 commit 5e1c722

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
@@ -2020,6 +2020,46 @@ def concerns(*args)
20202020
end
20212021
end
20222022

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

actionpack/lib/action_dispatch/routing/route_set.rb

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

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

103118
def add(name, route)
@@ -143,6 +158,62 @@ def length
143158
routes.length
144159
end
145160

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

628+
def add_url_helper(name, options, &block)
629+
named_routes.add_url_helper(name, options, &block)
630+
end
631+
557632
class Generator
558633
PARAMETERIZE = lambda do |name, value|
559634
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
@@ -263,7 +263,10 @@ def index
263263
assert_equal "WIN", last_response.body
264264
end
265265

266-
{ "development" => "baz", "production" => "bar" }.each do |mode, expected|
266+
{
267+
"development" => ["baz", "http://www.apple.com"],
268+
"production" => ["bar", "http://www.microsoft.com"]
269+
}.each do |mode, (expected_action, expected_url)|
267270
test "reloads routes when configuration is changed in #{mode}" do
268271
controller :foo, <<-RUBY
269272
class FooController < ApplicationController
@@ -274,12 +277,19 @@ def bar
274277
def baz
275278
render plain: "baz"
276279
end
280+
281+
def custom
282+
render plain: custom_url
283+
end
277284
end
278285
RUBY
279286

280287
app_file "config/routes.rb", <<-RUBY
281288
Rails.application.routes.draw do
282289
get 'foo', to: 'foo#bar'
290+
get 'custom', to: 'foo#custom'
291+
292+
url_helper(:custom) { "http://www.microsoft.com" }
283293
end
284294
RUBY
285295

@@ -288,16 +298,25 @@ def baz
288298
get "/foo"
289299
assert_equal "bar", last_response.body
290300

301+
get "/custom"
302+
assert_equal "http://www.microsoft.com", last_response.body
303+
291304
app_file "config/routes.rb", <<-RUBY
292305
Rails.application.routes.draw do
293306
get 'foo', to: 'foo#baz'
307+
get 'custom', to: 'foo#custom'
308+
309+
url_helper(:custom) { "http://www.apple.com" }
294310
end
295311
RUBY
296312

297313
sleep 0.1
298314

299315
get "/foo"
300-
assert_equal expected, last_response.body
316+
assert_equal expected_action, last_response.body
317+
318+
get "/custom"
319+
assert_equal expected_url, last_response.body
301320
end
302321
end
303322

@@ -358,6 +377,10 @@ class FooController < ApplicationController
358377
def index
359378
render plain: "foo"
360379
end
380+
381+
def custom
382+
render text: custom_url
383+
end
361384
end
362385
RUBY
363386

@@ -443,16 +466,19 @@ def index
443466
app_file "config/routes.rb", <<-RUBY
444467
Rails.application.routes.draw do
445468
get ':locale/foo', to: 'foo#index', as: 'foo'
469+
url_helper(:microsoft) { 'http://www.microsoft.com' }
446470
end
447471
RUBY
448472

449473
get "/en/foo"
450474
assert_equal "foo", last_response.body
451475
assert_equal "/en/foo", Rails.application.routes.url_helpers.foo_path(locale: "en")
476+
assert_equal "http://www.microsoft.com", Rails.application.routes.url_helpers.microsoft_url
452477

453478
app_file "config/routes.rb", <<-RUBY
454479
Rails.application.routes.draw do
455480
get ':locale/bar', to: 'bar#index', as: 'foo'
481+
url_helper(:apple) { 'http://www.apple.com' }
456482
end
457483
RUBY
458484

@@ -464,6 +490,11 @@ def index
464490
get "/en/bar"
465491
assert_equal "bar", last_response.body
466492
assert_equal "/en/bar", Rails.application.routes.url_helpers.foo_path(locale: "en")
493+
assert_equal "http://www.apple.com", Rails.application.routes.url_helpers.apple_url
494+
495+
assert_raises NoMethodError do
496+
assert_equal 'http://www.microsoft.com', Rails.application.routes.url_helpers.microsoft_url
497+
end
467498
end
468499

469500
test "resource routing with irregular inflection" do

0 commit comments

Comments
 (0)