actionpack 8.0.3 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +337 -158
  3. data/lib/abstract_controller/asset_paths.rb +4 -2
  4. data/lib/abstract_controller/base.rb +12 -3
  5. data/lib/abstract_controller/caching.rb +6 -3
  6. data/lib/abstract_controller/helpers.rb +1 -1
  7. data/lib/abstract_controller/logger.rb +2 -1
  8. data/lib/action_controller/api.rb +1 -0
  9. data/lib/action_controller/base.rb +2 -1
  10. data/lib/action_controller/caching.rb +1 -2
  11. data/lib/action_controller/form_builder.rb +1 -1
  12. data/lib/action_controller/log_subscriber.rb +18 -3
  13. data/lib/action_controller/metal/allow_browser.rb +1 -1
  14. data/lib/action_controller/metal/conditional_get.rb +25 -0
  15. data/lib/action_controller/metal/data_streaming.rb +1 -3
  16. data/lib/action_controller/metal/exceptions.rb +5 -0
  17. data/lib/action_controller/metal/flash.rb +1 -4
  18. data/lib/action_controller/metal/head.rb +3 -1
  19. data/lib/action_controller/metal/live.rb +9 -18
  20. data/lib/action_controller/metal/permissions_policy.rb +9 -0
  21. data/lib/action_controller/metal/rate_limiting.rb +30 -9
  22. data/lib/action_controller/metal/redirecting.rb +105 -13
  23. data/lib/action_controller/metal/renderers.rb +27 -6
  24. data/lib/action_controller/metal/rendering.rb +7 -1
  25. data/lib/action_controller/metal/request_forgery_protection.rb +18 -10
  26. data/lib/action_controller/metal/rescue.rb +9 -0
  27. data/lib/action_controller/railtie.rb +27 -8
  28. data/lib/action_controller/structured_event_subscriber.rb +112 -0
  29. data/lib/action_dispatch/http/cache.rb +111 -1
  30. data/lib/action_dispatch/http/filter_parameters.rb +5 -3
  31. data/lib/action_dispatch/http/mime_negotiation.rb +55 -1
  32. data/lib/action_dispatch/http/mime_types.rb +1 -0
  33. data/lib/action_dispatch/http/param_builder.rb +28 -27
  34. data/lib/action_dispatch/http/parameters.rb +3 -3
  35. data/lib/action_dispatch/http/permissions_policy.rb +4 -0
  36. data/lib/action_dispatch/http/query_parser.rb +12 -10
  37. data/lib/action_dispatch/http/request.rb +10 -5
  38. data/lib/action_dispatch/http/response.rb +16 -3
  39. data/lib/action_dispatch/http/url.rb +110 -14
  40. data/lib/action_dispatch/journey/gtg/simulator.rb +33 -12
  41. data/lib/action_dispatch/journey/gtg/transition_table.rb +33 -41
  42. data/lib/action_dispatch/journey/nodes/node.rb +2 -1
  43. data/lib/action_dispatch/journey/route.rb +45 -31
  44. data/lib/action_dispatch/journey/router/utils.rb +8 -14
  45. data/lib/action_dispatch/journey/router.rb +59 -81
  46. data/lib/action_dispatch/journey/routes.rb +7 -0
  47. data/lib/action_dispatch/journey/visitors.rb +55 -23
  48. data/lib/action_dispatch/journey/visualizer/fsm.js +4 -6
  49. data/lib/action_dispatch/log_subscriber.rb +7 -3
  50. data/lib/action_dispatch/middleware/cookies.rb +4 -2
  51. data/lib/action_dispatch/middleware/debug_exceptions.rb +7 -1
  52. data/lib/action_dispatch/middleware/debug_view.rb +11 -0
  53. data/lib/action_dispatch/middleware/exception_wrapper.rb +11 -5
  54. data/lib/action_dispatch/middleware/executor.rb +12 -2
  55. data/lib/action_dispatch/middleware/public_exceptions.rb +1 -5
  56. data/lib/action_dispatch/middleware/remote_ip.rb +9 -4
  57. data/lib/action_dispatch/middleware/session/cache_store.rb +17 -0
  58. data/lib/action_dispatch/middleware/templates/rescues/_copy_button.html.erb +1 -0
  59. data/lib/action_dispatch/middleware/templates/rescues/_source.html.erb +3 -2
  60. data/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb +9 -5
  61. data/lib/action_dispatch/middleware/templates/rescues/blocked_host.html.erb +1 -0
  62. data/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb +1 -0
  63. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +4 -0
  64. data/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +3 -0
  65. data/lib/action_dispatch/middleware/templates/rescues/layout.erb +50 -0
  66. data/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb +1 -0
  67. data/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb +1 -0
  68. data/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb +1 -0
  69. data/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb +1 -0
  70. data/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +1 -0
  71. data/lib/action_dispatch/railtie.rb +14 -2
  72. data/lib/action_dispatch/routing/inspector.rb +79 -56
  73. data/lib/action_dispatch/routing/mapper.rb +324 -172
  74. data/lib/action_dispatch/routing/redirection.rb +10 -7
  75. data/lib/action_dispatch/routing/route_set.rb +2 -4
  76. data/lib/action_dispatch/structured_event_subscriber.rb +20 -0
  77. data/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +2 -2
  78. data/lib/action_dispatch/testing/assertions/response.rb +14 -0
  79. data/lib/action_dispatch/testing/assertions/routing.rb +11 -3
  80. data/lib/action_dispatch/testing/integration.rb +1 -1
  81. data/lib/action_dispatch/testing/request_encoder.rb +9 -9
  82. data/lib/action_dispatch.rb +8 -0
  83. data/lib/action_pack/gem_version.rb +2 -2
  84. metadata +13 -10
@@ -44,6 +44,8 @@ module ActionDispatch
44
44
  "10.0.0.0/8", # private IPv4 range 10.x.x.x
45
45
  "172.16.0.0/12", # private IPv4 range 172.16.0.0 .. 172.31.255.255
46
46
  "192.168.0.0/16", # private IPv4 range 192.168.x.x
47
+ "169.254.0.0/16", # link-local IPv4 range 169.254.x.x
48
+ "fe80::/10", # link-local IPv6 range fe80::/10
47
49
  ].map { |proxy| IPAddr.new(proxy) }
48
50
 
49
51
  attr_reader :check_ip, :proxies
@@ -126,11 +128,11 @@ module ActionDispatch
126
128
  # left, which was presumably set by one of those proxies.
127
129
  def calculate_ip
128
130
  # Set by the Rack web server, this is a single value.
129
- remote_addr = ips_from(@req.remote_addr).last
131
+ remote_addr = sanitize_ips(ips_from(@req.remote_addr)).last
130
132
 
131
133
  # Could be a CSV list and/or repeated headers that were concatenated.
132
- client_ips = ips_from(@req.client_ip).reverse!
133
- forwarded_ips = ips_from(@req.x_forwarded_for).reverse!
134
+ client_ips = sanitize_ips(ips_from(@req.client_ip)).reverse!
135
+ forwarded_ips = sanitize_ips(@req.forwarded_for || []).reverse!
134
136
 
135
137
  # `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they
136
138
  # are both set, it means that either:
@@ -176,7 +178,10 @@ module ActionDispatch
176
178
  def ips_from(header) # :doc:
177
179
  return [] unless header
178
180
  # Split the comma-separated list into an array of strings.
179
- ips = header.strip.split(/[,\s]+/)
181
+ header.strip.split(/[,\s]+/)
182
+ end
183
+
184
+ def sanitize_ips(ips) # :doc:
180
185
  ips.select! do |ip|
181
186
  # Only return IPs that are valid according to the IPAddr#new method.
182
187
  range = IPAddr.new(ip).to_range
@@ -18,11 +18,16 @@ module ActionDispatch
18
18
  # * `expire_after` - The length of time a session will be stored before
19
19
  # automatically expiring. By default, the `:expires_in` option of the cache
20
20
  # is used.
21
+ # * `check_collisions` - Check if newly generated session ids aren't already in use.
22
+ # If for some reason 128 bits of randomness aren't considered secure enough to avoid
23
+ # collisions, this option can be enabled to ensure newly generated ids aren't in use.
24
+ # By default, it is set to `false` to avoid additional cache write operations.
21
25
  #
22
26
  class CacheStore < AbstractSecureStore
23
27
  def initialize(app, options = {})
24
28
  @cache = options[:cache] || Rails.cache
25
29
  options[:expire_after] ||= @cache.options[:expires_in]
30
+ @check_collisions = options[:check_collisions] || false
26
31
  super
27
32
  end
28
33
 
@@ -61,6 +66,18 @@ module ActionDispatch
61
66
  def get_session_with_fallback(sid)
62
67
  @cache.read(cache_key(sid.private_id)) || @cache.read(cache_key(sid.public_id))
63
68
  end
69
+
70
+ def generate_sid
71
+ if @check_collisions
72
+ loop do
73
+ sid = super
74
+ key = cache_key(sid.private_id)
75
+ break sid if @cache.write(key, {}, unless_exist: true, expires_in: default_options[:expire_after])
76
+ end
77
+ else
78
+ super
79
+ end
80
+ end
64
81
  end
65
82
  end
66
83
  end
@@ -0,0 +1 @@
1
+ <button onclick="copyAsText.bind(this)()">Copy as text</button>
@@ -11,8 +11,9 @@
11
11
  <tr>
12
12
  <td>
13
13
  <pre class="line_numbers">
14
- <% source_extract[:code].each_key do |line_number| %>
15
- <span><%= line_number -%></span>
14
+ <% source_extract[:code].each_key do |line| %>
15
+ <% file_url = editor_url(source_extract[:trace], line: line) %>
16
+ <span><%= link_to_if file_url, line, file_url -%></span>
16
17
  <% end %>
17
18
  </pre>
18
19
  </td>
@@ -13,13 +13,17 @@
13
13
  <% end %>
14
14
 
15
15
  <% traces.each do |name, trace| %>
16
- <div id="<%= "#{name.gsub(/\s/, '-')}-#{error_index}" %>" style="display: <%= (name == trace_to_show) ? 'block' : 'none' %>;">
16
+ <div id="<%= "#{name.gsub(/\s/, '-')}-#{error_index}" %>" class="trace-container" style="display: <%= (name == trace_to_show) ? 'block' : 'none' %>;">
17
17
  <code class="traces">
18
18
  <% trace.each do |frame| %>
19
- <a class="trace-frames trace-frames-<%= error_index %>" data-exception-object-id="<%= frame[:exception_object_id] %>" data-frame-id="<%= frame[:id] %>" href="#">
20
- <%= frame[:trace] %>
21
- </a>
22
- <br>
19
+ <div class="trace">
20
+ <% file_url = editor_url(frame[:trace]) %>
21
+ <%= file_url && link_to("✏️", file_url, class: "edit-icon") %>
22
+ <a class="trace-frames trace-frames-<%= error_index %>" data-exception-object-id="<%= frame[:exception_object_id] %>" data-frame-id="<%= frame[:id] %>" href="#">
23
+ <%= frame[:trace] %>
24
+ </a>
25
+ <br>
26
+ </div>
23
27
  <% end %>
24
28
  </code>
25
29
  </div>
@@ -1,4 +1,5 @@
1
1
  <header>
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>Blocked hosts: <%= @hosts.join(", ") %></h1>
3
4
  </header>
4
5
  <main role="main" id="container">
@@ -1,4 +1,5 @@
1
1
  <header>
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>
3
4
  <%= @exception_wrapper.exception_class_name %>
4
5
  <% if params_valid? && @request.parameters['controller'] %>
@@ -1,4 +1,5 @@
1
1
  <header role="banner">
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>
3
4
  <%= @exception.class.to_s %>
4
5
  <% if @request.parameters['controller'] %>
@@ -10,6 +11,9 @@
10
11
  <main role="main" id="container">
11
12
  <h2>
12
13
  <%= h @exception.message %>
14
+ <% if defined?(ActionText) && @exception.message.match?(%r{#{ActionText::RichText.table_name}}) %>
15
+ <br />To resolve this issue run: bin/rails action_text:install
16
+ <% end %>
13
17
  <% if defined?(ActiveStorage) && @exception.message.match?(%r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}}) %>
14
18
  <br />To resolve this issue run: bin/rails active_storage:install
15
19
  <% end %>
@@ -4,6 +4,9 @@
4
4
  <% end %>
5
5
 
6
6
  <%= @exception.message %>
7
+ <% if defined?(ActionText) && @exception.message.match?(%r{#{ActionText::RichText.table_name}}) %>
8
+ To resolve this issue run: bin/rails action_text:install
9
+ <% end %>
7
10
  <% if defined?(ActiveStorage) && @exception.message.match?(%r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}}) %>
8
11
  To resolve this issue run: bin/rails active_storage:install
9
12
  <% end %>
@@ -38,6 +38,22 @@
38
38
  padding: 0.5em 1.5em;
39
39
  }
40
40
 
41
+ header button {
42
+ appearance: none;
43
+ background-color: hsl(0 0% 0% / 0.2);
44
+ border: 0;
45
+ border-radius: 14px;
46
+ color: white;
47
+ float: right;
48
+ font-weight: 500;
49
+ height: 28px;
50
+ padding-inline: 14px;
51
+ margin: 0.35em 0;
52
+ }
53
+ header button:active {
54
+ background-color: hsl(0 0% 0% / 0.25);
55
+ }
56
+
41
57
  h1 {
42
58
  overflow-wrap: break-word;
43
59
  margin: 0.2em 0;
@@ -54,6 +70,30 @@
54
70
  font-size: 11px;
55
71
  }
56
72
 
73
+ .trace-container {
74
+ margin-top: 10px;
75
+ }
76
+
77
+ code.traces .trace {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 2px;
81
+ }
82
+
83
+ .edit-icon {
84
+ width: 16px;
85
+ height: 16px;
86
+ display: flex;
87
+ font-size: 13px;
88
+ align-items: center;
89
+ justify-content: center;
90
+ text-decoration: none;
91
+ }
92
+
93
+ .edit-icon:hover {
94
+ scale: 1.05;
95
+ }
96
+
57
97
  .response-heading, .request-heading {
58
98
  margin-top: 30px;
59
99
  }
@@ -274,11 +314,21 @@
274
314
  var toggleEnvDump = function() {
275
315
  return toggle('env_dump');
276
316
  }
317
+ var copyAsText = function() {
318
+ const text = document.getElementById("exception-message-for-copy").textContent;
319
+
320
+ navigator.clipboard.writeText(text).then(() => {
321
+ const beforeText = this.innerText;
322
+ this.innerText = "Copied!"
323
+ setTimeout(() => this.innerText = beforeText, 1000)
324
+ })
325
+ }
277
326
  </script>
278
327
  </head>
279
328
  <body>
280
329
 
281
330
  <%= yield %>
331
+ <script type="text/plain" id="exception-message-for-copy"><%= raw @exception_message_for_copy %></script>
282
332
 
283
333
  </body>
284
334
  </html>
@@ -1,4 +1,5 @@
1
1
  <header role="banner">
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>No view template for interactive request</h1>
3
4
  </header>
4
5
 
@@ -1,4 +1,5 @@
1
1
  <header role="banner">
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>Template is missing</h1>
3
4
  </header>
4
5
 
@@ -1,4 +1,5 @@
1
1
  <header role="banner">
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>Routing Error</h1>
3
4
  </header>
4
5
  <main role="main" id="container">
@@ -1,4 +1,5 @@
1
1
  <header role="banner">
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>
3
4
  <%= @exception_wrapper.exception_name %> in
4
5
  <%= @request.parameters["controller"].camelize if @request.parameters["controller"] %>#<%= @request.parameters["action"] %>
@@ -1,4 +1,5 @@
1
1
  <header role="banner">
2
+ <%= render "rescues/copy_button" %>
2
3
  <h1>Unknown action</h1>
3
4
  </header>
4
5
  <main role="main" id="container">
@@ -4,6 +4,7 @@
4
4
 
5
5
  require "action_dispatch"
6
6
  require "action_dispatch/log_subscriber"
7
+ require "action_dispatch/structured_event_subscriber"
7
8
  require "active_support/messages/rotation_configuration"
8
9
 
9
10
  module ActionDispatch
@@ -33,6 +34,7 @@ module ActionDispatch
33
34
 
34
35
  config.action_dispatch.ignore_leading_brackets = nil
35
36
  config.action_dispatch.strict_query_string_separator = nil
37
+ config.action_dispatch.verbose_redirect_logs = false
36
38
 
37
39
  config.action_dispatch.default_headers = {
38
40
  "X-Frame-Options" => "SAMEORIGIN",
@@ -55,8 +57,18 @@ module ActionDispatch
55
57
  ActionDispatch::Http::URL.secure_protocol = app.config.force_ssl
56
58
  ActionDispatch::Http::URL.tld_length = app.config.action_dispatch.tld_length
57
59
 
58
- ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets
59
- ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator
60
+ unless app.config.action_dispatch.domain_extractor.nil?
61
+ ActionDispatch::Http::URL.domain_extractor = app.config.action_dispatch.domain_extractor
62
+ end
63
+
64
+ unless app.config.action_dispatch.ignore_leading_brackets.nil?
65
+ ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets
66
+ end
67
+ unless app.config.action_dispatch.strict_query_string_separator.nil?
68
+ ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator
69
+ end
70
+
71
+ ActionDispatch.verbose_redirect_logs = app.config.action_dispatch.verbose_redirect_logs
60
72
 
61
73
  ActiveSupport.on_load(:action_dispatch_request) do
62
74
  self.ignore_accept_header = app.config.action_dispatch.ignore_accept_header
@@ -64,6 +64,14 @@ module ActionDispatch
64
64
  def engine?
65
65
  app.engine?
66
66
  end
67
+
68
+ def to_h
69
+ { name: name,
70
+ verb: verb,
71
+ path: path,
72
+ reqs: reqs,
73
+ source_location: source_location }
74
+ end
67
75
  end
68
76
 
69
77
  ##
@@ -72,30 +80,51 @@ module ActionDispatch
72
80
  # not use this class.
73
81
  class RoutesInspector # :nodoc:
74
82
  def initialize(routes)
75
- @engines = {}
76
- @routes = routes
83
+ @routes = wrap_routes(routes)
84
+ @engines = load_engines_routes
77
85
  end
78
86
 
79
87
  def format(formatter, filter = {})
80
- routes_to_display = filter_routes(normalize_filter(filter))
81
- routes = collect_routes(routes_to_display)
82
- if routes.none?
83
- formatter.no_routes(collect_routes(@routes), filter)
84
- return formatter.result
85
- end
88
+ all_routes = { nil => @routes }.merge(@engines)
86
89
 
87
- formatter.header routes
88
- formatter.section routes
89
-
90
- @engines.each do |name, engine_routes|
91
- formatter.section_title "Routes for #{name}"
92
- formatter.section engine_routes
90
+ all_routes.each do |engine_name, routes|
91
+ format_routes(formatter, filter, engine_name, routes)
93
92
  end
94
93
 
95
94
  formatter.result
96
95
  end
97
96
 
98
97
  private
98
+ def format_routes(formatter, filter, engine_name, routes)
99
+ routes = filter_routes(routes, normalize_filter(filter)).map(&:to_h)
100
+
101
+ formatter.section_title "Routes for #{engine_name || "application"}" if @engines.any?
102
+ if routes.any?
103
+ formatter.header routes
104
+ formatter.section routes
105
+ else
106
+ formatter.no_routes engine_name, routes, filter
107
+ end
108
+ formatter.footer routes
109
+ end
110
+
111
+ def wrap_routes(routes)
112
+ routes.routes.map { |route| RouteWrapper.new(route) }.reject(&:internal?)
113
+ end
114
+
115
+ def load_engines_routes
116
+ engine_routes = @routes.select(&:engine?)
117
+
118
+ engines = engine_routes.to_h do |engine_route|
119
+ engine_app_routes = engine_route.rack_app.routes
120
+ engine_app_routes = engine_app_routes.routes if engine_app_routes.is_a?(ActionDispatch::Routing::RouteSet)
121
+
122
+ [engine_route.endpoint, wrap_routes(engine_app_routes)]
123
+ end
124
+
125
+ engines
126
+ end
127
+
99
128
  def normalize_filter(filter)
100
129
  if filter[:controller]
101
130
  { controller: /#{filter[:controller].underscore.sub(/_?controller\z/, "")}/ }
@@ -115,39 +144,13 @@ module ActionDispatch
115
144
  end
116
145
  end
117
146
 
118
- def filter_routes(filter)
147
+ def filter_routes(routes, filter)
119
148
  if filter
120
- @routes.select do |route|
121
- route_wrapper = RouteWrapper.new(route)
122
- filter.any? { |filter_type, value| route_wrapper.matches_filter?(filter_type, value) }
149
+ routes.select do |route|
150
+ filter.any? { |filter_type, value| route.matches_filter?(filter_type, value) }
123
151
  end
124
152
  else
125
- @routes
126
- end
127
- end
128
-
129
- def collect_routes(routes)
130
- routes.collect do |route|
131
- RouteWrapper.new(route)
132
- end.reject(&:internal?).collect do |route|
133
- collect_engine_routes(route)
134
-
135
- { name: route.name,
136
- verb: route.verb,
137
- path: route.path,
138
- reqs: route.reqs,
139
- source_location: route.source_location }
140
- end
141
- end
142
-
143
- def collect_engine_routes(route)
144
- name = route.endpoint
145
- return unless route.engine?
146
- return if @engines[name]
147
-
148
- routes = route.rack_app.routes
149
- if routes.is_a?(ActionDispatch::Routing::RouteSet)
150
- @engines[name] = collect_routes(routes.routes)
153
+ routes
151
154
  end
152
155
  end
153
156
  end
@@ -171,27 +174,36 @@ module ActionDispatch
171
174
  def header(routes)
172
175
  end
173
176
 
174
- def no_routes(routes, filter)
175
- @buffer <<
176
- if routes.none?
177
- <<~MESSAGE
178
- You don't have any routes defined!
177
+ def footer(routes)
178
+ end
179
179
 
180
- Please add some routes in config/routes.rb.
181
- MESSAGE
182
- elsif filter.key?(:controller)
180
+ def no_routes(engine, routes, filter)
181
+ @buffer <<
182
+ if filter.key?(:controller)
183
183
  "No routes were found for this controller."
184
184
  elsif filter.key?(:grep)
185
185
  "No routes were found for this grep pattern."
186
+ elsif routes.none?
187
+ if engine
188
+ "No routes defined."
189
+ else
190
+ <<~MESSAGE
191
+ You don't have any routes defined!
192
+
193
+ Please add some routes in config/routes.rb.
194
+ MESSAGE
195
+ end
186
196
  end
187
197
 
188
- @buffer << "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html."
198
+ unless engine
199
+ @buffer << "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html."
200
+ end
189
201
  end
190
202
  end
191
203
 
192
204
  class Sheet < Base
193
205
  def section_title(title)
194
- @buffer << "\n#{title}:"
206
+ @buffer << "#{title}:"
195
207
  end
196
208
 
197
209
  def section(routes)
@@ -202,6 +214,10 @@ module ActionDispatch
202
214
  @buffer << draw_header(routes)
203
215
  end
204
216
 
217
+ def footer(routes)
218
+ @buffer << ""
219
+ end
220
+
205
221
  private
206
222
  def draw_section(routes)
207
223
  header_lengths = ["Prefix", "Verb", "URI Pattern"].map(&:length)
@@ -232,13 +248,17 @@ module ActionDispatch
232
248
  end
233
249
 
234
250
  def section_title(title)
235
- @buffer << "\n#{"[ #{title} ]"}"
251
+ @buffer << "#{"[ #{title} ]"}"
236
252
  end
237
253
 
238
254
  def section(routes)
239
255
  @buffer << draw_expanded_section(routes)
240
256
  end
241
257
 
258
+ def footer(routes)
259
+ @buffer << ""
260
+ end
261
+
242
262
  private
243
263
  def draw_expanded_section(routes)
244
264
  routes.map.each_with_index do |r, i|
@@ -269,7 +289,7 @@ module ActionDispatch
269
289
  super
270
290
  end
271
291
 
272
- def no_routes(routes, filter)
292
+ def no_routes(engine, routes, filter)
273
293
  @buffer <<
274
294
  if filter.none?
275
295
  "No unused routes found."
@@ -300,6 +320,9 @@ module ActionDispatch
300
320
  def header(routes)
301
321
  end
302
322
 
323
+ def footer(routes)
324
+ end
325
+
303
326
  def no_routes(*)
304
327
  @buffer << <<~MESSAGE
305
328
  <p>You don't have any routes defined!</p>