padrino-core 0.18.3 → 0.18.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0292970725ed26c9ba8a10d8f5b735d1a65c5fd35059a42f8d11b1c226fab310'
4
- data.tar.gz: f9420665a99ea9e3647212aa634ae115e5823bb7a03a4f09e4272fcb1bcaa935
3
+ metadata.gz: be05253b26516488a094d26e9867de40bf05e08fef5d5522bd0cd0d17cd31c9b
4
+ data.tar.gz: 7c6cd5ead1fd9ffac74c8c2dad9d74d64156af2c9ae04bf33bbbc5a0e6a8d177
5
5
  SHA512:
6
- metadata.gz: 8ba4b407587927ea087c9c00367bc81d3c2a4384e6653d07b7a5b0b233f4ef6e15e7fc9bf3bacda94e47c66a0bdfa5d8db9a65a664e8d8db1dc79ae4abfe27b5
7
- data.tar.gz: 026a7d57274931d997a8c624ac157bd5973755756c607749ea6deb066f84c8c13861287823fa8a35d6bca952a869a7e961383239be25e738ddd3e9a3d1dede3c
6
+ metadata.gz: 62df53f97d43739feaf1a71c3b09f27f39f28972ad242f321a60f41327ec3fb2dc854b75f7b109fe02cb3389a1ee10e13d20f6e9a01ce424dd02ed06c5309581
7
+ data.tar.gz: fcac6597b21ff08a3c7c20d35ddfeaeb1b7e9ac5dbfd5e83ab9bd0a9d1c705f3c3474da8daaa12d6519c9e9c1227bbcf66057f801719748db3e74fa859ce3e1b
@@ -38,6 +38,7 @@ module Padrino
38
38
 
39
39
  def initialize(value, options = {})
40
40
  super(value.to_s)
41
+ options = options.dup
41
42
  @map = options.delete(:map)
42
43
  @optional = options.delete(:optional)
43
44
  @options = options
@@ -339,10 +340,10 @@ module Padrino
339
340
  # @see http://padrinorb.com/guides/controllers/route-filters/
340
341
  #
341
342
  def construct_filter(*args, &block)
342
- options = args.last.is_a?(Hash) ? args.pop : {}
343
+ options = args.last.is_a?(Hash) ? args.pop.dup : {}
343
344
  if (except = options.delete(:except))
344
345
  raise 'You cannot use :except with other options specified' unless args.empty? && options.empty?
345
- except = Array(except)
346
+ except = Array(except).dup
346
347
  options = except.last.is_a?(Hash) ? except.pop : {}
347
348
  end
348
349
  Filter.new(!except, @_controller, options, Array(except || args), &block)
@@ -376,8 +377,7 @@ module Padrino
376
377
  return super() unless name
377
378
  defaults = { optional: false, map: name.to_s }
378
379
  options = defaults.merge(options)
379
- @_parent = Array(@_parent) unless @_parent.is_a?(Array)
380
- @_parent << Parent.new(name, options)
380
+ @_parent = Array(@_parent) + [Parent.new(name, options)]
381
381
  end
382
382
 
383
383
  ##
@@ -462,7 +462,7 @@ module Padrino
462
462
  # url(:index, fragment: 'comments')
463
463
  #
464
464
  def url(*args)
465
- params = args.last.is_a?(Hash) ? args.pop : {}
465
+ params = args.last.is_a?(Hash) ? args.pop.dup : {}
466
466
  fragment = params.delete(:fragment) || params.delete(:anchor)
467
467
  path = make_path_with_params(args, value_to_param(params))
468
468
  rebase_url(fragment ? "#{path}##{fragment}" : path)
@@ -497,8 +497,8 @@ module Padrino
497
497
  def rebase_url(url)
498
498
  if url.start_with?('/')
499
499
  new_url = String.new
500
- new_url << conform_uri(ENV['RACK_BASE_URI']) if ENV['RACK_BASE_URI']
501
- new_url << conform_uri(uri_root) if defined?(uri_root)
500
+ new_url << conformed_uri(:@_conformed_base_uri, ENV['RACK_BASE_URI']) if ENV['RACK_BASE_URI']
501
+ new_url << conformed_uri(:@_conformed_uri_root, uri_root) if defined?(uri_root)
502
502
  new_url << url
503
503
  else
504
504
  url.empty? ? '/' : url
@@ -542,7 +542,7 @@ module Padrino
542
542
  # Saves controller options, yields the block, restores controller options.
543
543
  def with_new_options(*args)
544
544
  original = {}
545
- options = args.last.is_a?(Hash) ? args.pop : {}
545
+ options = args.last.is_a?(Hash) ? args.pop.dup : {}
546
546
 
547
547
  CONTROLLER_OPTIONS.each { |key| replace_instance_variable(original, "@_#{key}", options.delete(key)) }
548
548
  replace_instance_variable(original, :@_controller, args)
@@ -594,6 +594,17 @@ module Padrino
594
594
  uri_string.gsub(%r{^(?!/)(.*)}, '/\1').gsub(%r{/+$}, '')
595
595
  end
596
596
 
597
+ # conform_uri, remembered in +ivar+ for the last value it was given (the base URI and
598
+ # uri_root rarely change). The memo is a frozen [value, result] pair, replaced whole.
599
+ def conformed_uri(ivar, uri_string)
600
+ memo = instance_variable_get(ivar)
601
+ return memo[1] if memo && memo[0] == uri_string
602
+
603
+ memo = [uri_string.dup.freeze, conform_uri(uri_string).freeze].freeze
604
+ instance_variable_set(ivar, memo)
605
+ memo[1]
606
+ end
607
+
597
608
  ##
598
609
  # Rewrite default routes.
599
610
  #
@@ -22,6 +22,7 @@ module Padrino
22
22
  route.index = index
23
23
  route.regexp = /\A(?<_#{index}>#{route.matcher.to_regexp})\z/
24
24
  end
25
+ index_by_prefix!
25
26
  @compiled = true
26
27
  end
27
28
 
@@ -46,11 +47,15 @@ module Padrino
46
47
  # Calls routes by using request.
47
48
  #
48
49
  def call_by_request(request)
50
+ path_info = pattern = nil
49
51
  rotation do |offset|
50
- pattern = decode_pattern(request.path_info)
52
+ # A route's filters may change the path before passing; decode it again only then.
53
+ unless path_info && path_info == request.path_info
54
+ path_info = request.path_info.dup
55
+ pattern = decode_pattern(path_info)
56
+ end
51
57
  if (route = match?(offset, pattern))
52
- params = route.params_for(pattern, request.params)
53
- yield(route, params) if route.verb == request.request_method
58
+ yield(route, route.params_for(pattern, request.params)) if route.verb == request.request_method
54
59
  route
55
60
  end
56
61
  end
@@ -72,9 +77,64 @@ module Padrino
72
77
  def match?(offset, path)
73
78
  return unless path
74
79
 
75
- @routes[offset..].find do |route|
76
- route.regexp === path || (path.end_with?('/') && route.regexp === path[0..-2])
80
+ trimmed = path[0..-2] if path.end_with?('/')
81
+ routes = @routes
82
+ # The index holds positions in routes as they were compiled, so it is used only while
83
+ # routes still holds the same routes in the same order. After any other edit to routes,
84
+ # match? looks through them one by one.
85
+ if (prefix_index = @prefix_index) && path.length >= PREFIX_SIZE && routes == prefix_index[2]
86
+ by_prefix, unprefixed, = prefix_index
87
+ candidates = by_prefix.fetch(path[0, PREFIX_SIZE], unprefixed)
88
+ from = candidates.bsearch_index { |index| index >= offset }
89
+ return unless from
90
+
91
+ from.upto(candidates.size - 1) do |at|
92
+ route = routes[candidates[at]]
93
+ regexp = route.regexp
94
+ return route if regexp.match?(path) || (trimmed && regexp.match?(trimmed))
95
+ end
96
+ return
97
+ end
98
+ while (route = routes[offset])
99
+ regexp = route.regexp
100
+ # A route added after compiling has no regexp and matches nothing.
101
+ return route if regexp && (regexp.match?(path) || (trimmed && regexp.match?(trimmed)))
102
+ offset += 1
103
+ end
104
+ end
105
+
106
+ # How many leading characters of a path pick the routes to try (index_by_prefix!).
107
+ PREFIX_SIZE = 3
108
+ # The start of a Sinatra pattern that can only match itself: plain characters, up to the
109
+ # first one that may be special (':', '*', '(', '?', '{', '\\', '+' ...).
110
+ LITERAL_START = %r{\A[A-Za-z0-9_\-/.~]*}
111
+
112
+ ##
113
+ # Sorts the routes by the first PREFIX_SIZE characters a path they match must start with,
114
+ # so that match? only tries the routes that can match it. A route whose pattern doesn't
115
+ # fix that many (a Regexp, a capture or group early, an optional character, a '|') is tried
116
+ # for every path.
117
+ #
118
+ def index_by_prefix!
119
+ prefixed = Hash.new { |hash, key| hash[key] = [] }
120
+ unprefixed = []
121
+ @routes.each_with_index do |route, index|
122
+ (prefix = literal_prefix(route)) ? prefixed[prefix] << index : unprefixed << index
77
123
  end
124
+ by_prefix = prefixed.transform_values { |indexes| (indexes + unprefixed).sort.freeze }.freeze
125
+ # One frozen value, set in one assignment: match? reads it once, so a thread matching while
126
+ # another compiles the same engine sees the lists and routes from the same compile.
127
+ @prefix_index = [by_prefix, unprefixed.freeze, @routes.dup.freeze].freeze
128
+ end
129
+
130
+ # The first PREFIX_SIZE characters every path the route matches starts with, or nil.
131
+ def literal_prefix(route)
132
+ path = route.original_path
133
+ return unless path.is_a?(String) && !path.include?('|')
134
+
135
+ literal = path[LITERAL_START]
136
+ literal = literal[0..-2] if path[literal.length] == '?'
137
+ literal[0, PREFIX_SIZE] if literal.length >= PREFIX_SIZE
78
138
  end
79
139
 
80
140
  ##
@@ -82,11 +142,12 @@ module Padrino
82
142
  #
83
143
  def rotation(offset = 0)
84
144
  compile! unless compiled?
85
- loop.with_object([]) do |_, candidacies|
86
- return candidacies unless (route = yield(offset))
145
+ candidacies = []
146
+ while (route = yield(offset))
87
147
  candidacies << route
88
148
  offset = route.index.next
89
149
  end
150
+ candidacies
90
151
  end
91
152
 
92
153
  ##
@@ -97,6 +158,11 @@ module Padrino
97
158
  # (a '%0A' left by '%250A'), matches no route either.
98
159
  #
99
160
  def decode_pattern(pattern)
161
+ # Without an escape, decoding an ASCII path only changes its encoding.
162
+ if pattern.ascii_only? && !pattern.include?('%') && Encoding.default_external.ascii_compatible?
163
+ return pattern.include?("\n") ? nil : pattern.dup.force_encoding(Encoding::UTF_8)
164
+ end
165
+
100
166
  pattern = pattern.encode(Encoding.default_external)
101
167
  return if pattern.match?(/%(?!\h\h)/)
102
168
 
@@ -5,6 +5,8 @@ module Padrino
5
5
  class Matcher
6
6
  # To count group of regexp
7
7
  GROUP_REGEXP = /\((?!\?:|\?!|\?<=|\?<!|\?=).+?\)/
8
+ # Most key lists a matcher remembers for expand_path.
9
+ MAX_EXACT_KEYS = 32
8
10
 
9
11
  ##
10
12
  # Constructs an instance of PathRouter::Matcher.
@@ -45,9 +47,14 @@ module Padrino
45
47
  def expand(params)
46
48
  params = @default_values.merge(params) if @default_values.is_a?(Hash)
47
49
  params, query = params.each_with_object([{}, {}]) do |(key, val), parts|
48
- parts[handler.names.include?(key.to_s) ? 0 : 1][key] = val
50
+ if names.include?(key.to_s)
51
+ # Mustermann escapes an Integer through a slow path; its digits need no escaping.
52
+ parts[0][key] = val.instance_of?(Integer) ? val.to_s : val
53
+ else
54
+ parts[1][key] = val
55
+ end
49
56
  end
50
- expanded_path = handler.expand(:append, params)
57
+ expanded_path = expand_path(params)
51
58
  expanded_path += "?#{Padrino::Utils.build_uri_query(query)}" unless query.empty?
52
59
  expanded_path
53
60
  end
@@ -106,7 +113,7 @@ module Padrino
106
113
  # @see Regexp#names
107
114
  #
108
115
  def names
109
- handler.names
116
+ @names ||= handler.names.dup.freeze
110
117
  end
111
118
 
112
119
  ##
@@ -122,6 +129,33 @@ module Padrino
122
129
 
123
130
  private
124
131
 
132
+ ##
133
+ # Mustermann's expand(:append, params). When the params fill one of the
134
+ # pattern's forms exactly, :append has nothing to append, and expand(:raise)
135
+ # gives the same path faster. Whether they do depends only on the keys,
136
+ # so it is found out once per list of keys, for params whose values are
137
+ # all Strings or Symbols (Integers arrive as Strings). Any other value
138
+ # (nil, an Array, an object) goes through :append and leaves the memo alone.
139
+ #
140
+ def expand_path(params)
141
+ return handler.expand(:append, params) unless params.all? { |_, value| value.instance_of?(String) || value.instance_of?(Symbol) }
142
+
143
+ keys = params.keys
144
+ exact = (@exact_keys ||= {}.freeze)[keys]
145
+ return handler.expand(:raise, params) if exact
146
+
147
+ path = handler.expand(:append, params)
148
+ if exact.nil? && @exact_keys.size < MAX_EXACT_KEYS
149
+ exact = begin
150
+ handler.expand(:raise, params) == path
151
+ rescue Mustermann::ExpandError, ArgumentError
152
+ false
153
+ end
154
+ @exact_keys = @exact_keys.merge(keys.freeze => exact).freeze
155
+ end
156
+ path
157
+ end
158
+
125
159
  ##
126
160
  # Creates a hash with indifferent access.
127
161
  #
@@ -4,7 +4,17 @@ module Padrino
4
4
  ##
5
5
  # The accessors are useful to access from PathRouter::Router
6
6
  #
7
- attr_accessor :name, :capture, :order, :options, :index, :regexp
7
+ attr_accessor :capture, :order, :options, :index, :regexp
8
+
9
+ ##
10
+ # The route's name (url generation finds routes by it).
11
+ #
12
+ attr_reader :name
13
+
14
+ def name=(name)
15
+ @name = name
16
+ @router&.named_routes_changed!
17
+ end
8
18
 
9
19
  ##
10
20
  # A reader for compile option
@@ -16,6 +26,14 @@ module Padrino
16
26
  #
17
27
  attr_writer :router
18
28
 
29
+ ##
30
+ # Sets the route's router unless it has one (a route pushed onto Router#routes directly).
31
+ #
32
+ # @api private
33
+ def adopt(router)
34
+ @router ||= router
35
+ end
36
+
19
37
  ##
20
38
  # The accessors will be used in other classes
21
39
  #
@@ -32,6 +32,7 @@ module Padrino
32
32
  route = Route.new(path, verb, options, &block)
33
33
  route.router = self
34
34
  @routes << route
35
+ @named_routes = nil
35
36
  route
36
37
  end
37
38
 
@@ -56,13 +57,17 @@ module Padrino
56
57
  #
57
58
  def path(name, *args)
58
59
  params = args.last.is_a?(Hash) ? args.pop : {}
59
- candidates = @routes.select { |route| route.name == name }
60
- raise InvalidRouteException if candidates.empty?
61
- i = 0
62
- route = candidates.sort_by! do |candidate|
60
+ candidates = named_routes[name]
61
+ raise InvalidRouteException unless candidates
62
+ route = candidates.first
63
+ # Candidates that all take the same params (a GET and its HEAD) rank in their order.
64
+ unless candidates.all? { |candidate| candidate.matcher.names == route.matcher.names }
65
+ keys = params.keys.map(&:to_s)
63
66
  # Tries to find the route that matches more, but with fewer names, in stable order
64
- [(params.keys.map(&:to_s) - candidate.matcher.names).length, candidate.matcher.names.size, i += 1]
65
- end.shift
67
+ route = candidates.each_with_index.min_by do |candidate, i|
68
+ [(keys - candidate.matcher.names).length, candidate.matcher.names.size, i]
69
+ end.first
70
+ end
66
71
  matcher = route.matcher
67
72
  params_for_expand = params.dup
68
73
  if !args.empty? && matcher.mustermann?
@@ -86,6 +91,7 @@ module Padrino
86
91
  # Resets all routes, current order and preparation.
87
92
  #
88
93
  def reset!
94
+ @named_routes = nil
89
95
  @routes = []
90
96
  @current_order = 0
91
97
  @prepared = nil
@@ -107,10 +113,39 @@ module Padrino
107
113
  @prepared = true
108
114
  return if @current_order.zero?
109
115
  @routes.sort_by!(&:order)
116
+ @named_routes = nil
117
+ end
118
+
119
+ ##
120
+ # Forgets the routes by name, after a route was added, sorted or renamed.
121
+ #
122
+ # @api private
123
+ def named_routes_changed!
124
+ @named_routes = nil
110
125
  end
111
126
 
112
127
  private
113
128
 
129
+ ##
130
+ # The routes by name, in the order of +routes+, rebuilt when +routes+ changes size so
131
+ # that a route added to or deleted from +routes+ directly is seen.
132
+ # ceiling: a direct edit that keeps the size (one route swapped for another) isn't seen,
133
+ # nor is a rename of a route this router shares with, or took from, another router;
134
+ # rebuild on every call if that has to work.
135
+ #
136
+ def named_routes
137
+ size = @routes.size
138
+ memo = @named_routes
139
+ return memo[1] if memo && memo[0] == size
140
+
141
+ # A route pushed onto +routes+ directly has no router; give it this one, so that
142
+ # renaming it clears this index.
143
+ @routes.each { |route| route.adopt(self) }
144
+ by_name = @routes.group_by(&:name).freeze
145
+ @named_routes = [size, by_name].freeze
146
+ by_name
147
+ end
148
+
114
149
  ##
115
150
  # Returns true if the router has been prepared.
116
151
  #
@@ -6,7 +6,7 @@
6
6
  #
7
7
  module Padrino
8
8
  # The version constant for the current version of Padrino.
9
- VERSION = '0.18.3' unless defined?(Padrino::VERSION)
9
+ VERSION = '0.18.5' unless defined?(Padrino::VERSION)
10
10
 
11
11
  #
12
12
  # The current Padrino version.
data/test/test_filters.rb CHANGED
@@ -416,6 +416,49 @@ describe 'Filters' do
416
416
  assert_equal 'before', body
417
417
  end
418
418
 
419
+ it 'should apply one except options hash to each filter it is passed to' do
420
+ opts = { except: :index }
421
+ mock_app do
422
+ before(opts) { @first = 'first' }
423
+ before(opts) { @second = 'second' }
424
+ get(:index) { "#{@first}#{@second}" }
425
+ get(:main) { "#{@first}#{@second}" }
426
+ end
427
+ get '/'
428
+ assert_equal '', body
429
+ get '/main'
430
+ assert_equal 'firstsecond', body
431
+ assert_equal({ except: :index }, opts)
432
+ end
433
+
434
+ it 'should apply one except list with options to each filter it is passed to' do
435
+ except = [:index, { agent: /IE/ }]
436
+ mock_app do
437
+ before(except: except) { @first = 'first' }
438
+ before(except: except) { @second = 'second' }
439
+ get(:index) { "#{@first}#{@second}" }
440
+ get(:main) { "#{@first}#{@second}" }
441
+ end
442
+ get '/main', {}, { 'HTTP_USER_AGENT' => 'IE' }
443
+ assert_equal '', body
444
+ get '/main'
445
+ assert_equal 'firstsecond', body
446
+ assert_equal [:index, { agent: /IE/ }], except
447
+ end
448
+
449
+ it 'should accept frozen except options' do
450
+ mock_app do
451
+ before({ except: :index }.freeze) { @first = 'first' }
452
+ before(except: [:index, { agent: /IE/ }.freeze].freeze) { @second = 'second' }
453
+ get(:index) { "#{@first}#{@second}" }
454
+ get(:main) { "#{@first}#{@second}" }
455
+ end
456
+ get '/'
457
+ assert_equal '', body
458
+ get '/main'
459
+ assert_equal 'firstsecond', body
460
+ end
461
+
419
462
  it 'should be able to filter excluding based on a symbol when specify the multiple routes and use nested controller' do
420
463
  mock_app do
421
464
  controller :test, :nested do
data/test/test_routing.rb CHANGED
@@ -434,6 +434,17 @@ describe 'Routing' do
434
434
  end
435
435
  end
436
436
 
437
+ it 'should give 404 for a path with a raw newline' do
438
+ mock_app do
439
+ get('/names/:name') { 'name' }
440
+ end
441
+ ["/names/a\nb", "/names/a\n"].each do |path|
442
+ env = Rack::MockRequest.env_for('/')
443
+ env['PATH_INFO'] = path
444
+ assert_equal 404, @app.call(env)[0], path.inspect
445
+ end
446
+ end
447
+
437
448
  it 'should cascade a path with a control character to the next app at the same mount' do
438
449
  first = mock_app do
439
450
  before('/admin') { halt 401, 'first' }
@@ -2440,6 +2451,89 @@ describe 'Routing' do
2440
2451
  assert_equal 1, counter
2441
2452
  end
2442
2453
 
2454
+ describe 'with options hashes and lists reused or frozen' do
2455
+ def mock_app_needing_ok(&block)
2456
+ mock_app do
2457
+ set(:needs_ok) { |on| condition { halt 403, 'no ok' unless params[:ok] } if on }
2458
+ instance_eval(&block)
2459
+ end
2460
+ end
2461
+
2462
+ it 'should apply the options to each controller and leave the hash unchanged' do
2463
+ opts = { conditions: { needs_ok: true }, map: '/area' }
2464
+ mock_app_needing_ok do
2465
+ controller(:a, opts) { get(:one) { 'one' } }
2466
+ controllers(:b, opts) { get(:two) { 'two' } }
2467
+ end
2468
+ %w[one two].each do |name|
2469
+ get "/area/#{name}"
2470
+ assert_equal 'no ok', body
2471
+ get "/area/#{name}", ok: 1
2472
+ assert_equal name, body
2473
+ end
2474
+ assert_equal({ conditions: { needs_ok: true }, map: '/area' }, opts)
2475
+ end
2476
+
2477
+ it 'should accept a frozen options hash' do
2478
+ opts = { conditions: { needs_ok: true } }.freeze
2479
+ mock_app_needing_ok do
2480
+ controller(:a, opts) { get(:index) { 'a' } }
2481
+ controller(:b, opts) { get(:index) { 'b' } }
2482
+ end
2483
+ %w[a b].each do |name|
2484
+ get "/#{name}"
2485
+ assert_equal 'no ok', body
2486
+ get "/#{name}", ok: 1
2487
+ assert_equal name, body
2488
+ end
2489
+ end
2490
+
2491
+ it 'should accept a frozen parent list and a parent declared inside the controller' do
2492
+ mock_app do
2493
+ controller(:product, parent: [:shop].freeze) do
2494
+ parent :category
2495
+ get(:show) { 'product' }
2496
+ end
2497
+ end
2498
+ get '/shop/1/category/2/product/show'
2499
+ assert_equal 'product', body
2500
+ end
2501
+
2502
+ it 'should leave the options given to a parent unchanged' do
2503
+ opts = { map: 'store', optional: true }.freeze
2504
+ first = Padrino::Routing::Parent.new(:a, opts)
2505
+ second = Padrino::Routing::Parent.new(:b, opts)
2506
+ assert_equal ['store', true], [first.map, first.optional]
2507
+ assert_equal ['store', true], [second.map, second.optional]
2508
+ end
2509
+
2510
+ it 'should keep the fragment of a params hash used for several urls' do
2511
+ mock_app { get(:show, with: :id) { 'show' } }
2512
+ params = { id: 1, fragment: 'top' }
2513
+ assert_equal '/show/1#top', @app.url(:show, params)
2514
+ assert_equal '/show/1#top', @app.url(:show, params)
2515
+ assert_equal '/show/1', @app.url(:show, { id: 1 }.freeze)
2516
+ end
2517
+
2518
+ it 'should not add a parent declared in a controller to the parents it was given' do
2519
+ parents = [:shop]
2520
+ mock_app do
2521
+ controller(:product, parent: parents) do
2522
+ parent :category
2523
+ get(:show) { 'product' }
2524
+ end
2525
+ controller(:item, parent: parents) { get(:show) { 'item' } }
2526
+ end
2527
+ get '/shop/1/category/2/product/show'
2528
+ assert_equal 'product', body
2529
+ get '/shop/1/item/show'
2530
+ assert_equal 'item', body
2531
+ get '/shop/1/category/2/item/show'
2532
+ assert_equal 404, status
2533
+ assert_equal [:shop], parents
2534
+ end
2535
+ end
2536
+
2443
2537
  it 'should parse params with class level provides' do
2444
2538
  mock_app do
2445
2539
  controllers :posts, provides: %i[html js] do
@@ -3136,4 +3230,280 @@ describe 'Routing' do
3136
3230
  assert_equal 'GET /posts/:id', outer_env['sinatra.route']
3137
3231
  end
3138
3232
  end
3233
+
3234
+ describe 'route matching' do
3235
+ it 'matches the path a route rewrote before passing' do
3236
+ mock_app do
3237
+ get(:rewrite, map: '/rewrite/:id') do
3238
+ request.path_info = '/target'
3239
+ pass
3240
+ end
3241
+ get(:in_place, map: '/in_place/:id') do
3242
+ request.path_info.replace('/target')
3243
+ pass
3244
+ end
3245
+ get(:target, map: '/target') { 'target' }
3246
+ end
3247
+ get '/rewrite/1'
3248
+ assert_equal 200, status
3249
+ assert_equal 'target', body
3250
+ get '/in_place/1'
3251
+ assert_equal 200, status
3252
+ assert_equal 'target', body
3253
+ end
3254
+
3255
+ it 'keeps serving its routes when a route is added after a request' do
3256
+ mock_app do
3257
+ get('/first') { 'first' }
3258
+ end
3259
+ get '/first'
3260
+ @app.get('/second') { 'second' }
3261
+ get '/first'
3262
+ assert_equal 'first', body
3263
+ get '/m'
3264
+ assert_equal 404, status
3265
+ get '/missing'
3266
+ assert_equal 404, status
3267
+ end
3268
+
3269
+ it 'keeps serving its routes when routes are removed or inserted after a request' do
3270
+ mock_app do
3271
+ get(:gone, map: '/gone') { 'gone' }
3272
+ get(:abc, map: '/abc') { 'abc' }
3273
+ get(:cap, map: '/:x') { 'cap' }
3274
+ end
3275
+ get '/abc'
3276
+ @app.router.routes.reject! { |route| route.name == :gone }
3277
+ get '/abc'
3278
+ assert_equal 'abc', body
3279
+ get '/zz'
3280
+ assert_equal 'cap', body
3281
+ @app.router.routes.unshift(Padrino::PathRouter::Route.new('/new', :get))
3282
+ get '/abc'
3283
+ assert_equal 'abc', body
3284
+ get '/gone'
3285
+ assert_equal 'cap', body
3286
+ end
3287
+
3288
+ it 'keeps serving its routes when routes are reordered after a request' do
3289
+ mock_app do
3290
+ get('/abc') { 'abc' }
3291
+ get('/abd') { 'abd' }
3292
+ get('/zzz') { 'zzz' }
3293
+ end
3294
+ get '/abc'
3295
+ routes = @app.router.routes
3296
+ first = routes.index { |route| route.original_path == '/abc' && route.verb == 'GET' }
3297
+ last = routes.rindex { |route| route.original_path == '/zzz' && route.verb == 'GET' }
3298
+ routes[first], routes[last] = routes[last], routes[first]
3299
+ get '/abc'
3300
+ assert_equal 200, status
3301
+ assert_equal 'abc', body
3302
+ get '/zzz'
3303
+ assert_equal 'zzz', body
3304
+ end
3305
+
3306
+ it 'matches routes whose pattern starts with an optional or variable part' do
3307
+ mock_app do
3308
+ get('/colou?r') { 'color' }
3309
+ get('/xy?z') { 'optional' }
3310
+ get('/one|/two') { 'union' }
3311
+ get('/:lang/start') { 'capture' }
3312
+ get('/*/files') { 'splat' }
3313
+ get('/ab') { 'short' }
3314
+ get(%r{/rx[0-9]+}) { 'regexp' }
3315
+ end
3316
+ { '/color' => 'color', '/colour' => 'color', '/xz' => 'optional', '/xyz' => 'optional', '/two' => 'union', '/de/start' => 'capture',
3317
+ '/a/b/files' => 'splat', '/ab' => 'short', '/ab/' => 'short', '/rx12' => 'regexp' }.each do |path, expected|
3318
+ get path
3319
+ assert_equal expected, body, path
3320
+ end
3321
+ end
3322
+
3323
+ it 'picks the routes to try by the decoded path' do
3324
+ mock_app do
3325
+ get('/abc/first') { 'first' }
3326
+ get('/ABC/x') { 'upper' }
3327
+ get('/a/') { 'slash' }
3328
+ end
3329
+ get '/%61bc/first'
3330
+ assert_equal 200, status
3331
+ assert_equal 'first', body
3332
+ get '/ABC/x'
3333
+ assert_equal 200, status
3334
+ assert_equal 'upper', body
3335
+ get '/a/'
3336
+ assert_equal 200, status
3337
+ assert_equal 'slash', body
3338
+ end
3339
+
3340
+ it 'lets a StopIteration raised in a route reach the error handlers' do
3341
+ mock_app do
3342
+ set :raise_errors, false
3343
+ set :show_exceptions, false
3344
+ get('/boom') { [].each.next }
3345
+ error(StopIteration) { 'handled' }
3346
+ end
3347
+ get '/boom'
3348
+ assert_equal 500, status
3349
+ assert_equal 'handled', body
3350
+ end
3351
+
3352
+ it 'serves a route when an earlier route for another verb matches the path' do
3353
+ mock_app do
3354
+ set :raise_errors, false
3355
+ set :show_exceptions, false
3356
+ post('(/:lang)?') { 'post' }
3357
+ get('/') { 'root' }
3358
+ end
3359
+ get '/'
3360
+ assert_equal 200, status
3361
+ assert_equal 'root', body
3362
+ end
3363
+
3364
+ it 'tries routes in the order they were defined, whatever their pattern starts with' do
3365
+ mock_app do
3366
+ get('/abc/first') { 'literal' }
3367
+ get('/:name/first') { 'capture' }
3368
+ get('/:name/second') { 'capture' }
3369
+ get('/abc/second') { 'literal' }
3370
+ end
3371
+ get '/abc/first'
3372
+ assert_equal 'literal', body
3373
+ get '/abc/second'
3374
+ assert_equal 'capture', body
3375
+ end
3376
+ end
3377
+
3378
+ describe 'url generation after routes change' do
3379
+ it 'finds a renamed route by its new name' do
3380
+ mock_app do
3381
+ get(:old, map: '/page') { 'page' }
3382
+ end
3383
+ assert_equal '/page', @app.url(:old)
3384
+ @app.routes.each { |route| route.name = :new if route.name == :old }
3385
+ assert_equal '/page', @app.url(:new)
3386
+ assert_raises(Padrino::Routing::UnrecognizedException) { @app.url(:old) }
3387
+ end
3388
+
3389
+ it 'uses the routes defined after reset_routes!' do
3390
+ mock_app do
3391
+ get(:page, map: '/old') { 'old' }
3392
+ end
3393
+ assert_equal '/old', @app.url(:page)
3394
+ @app.reset_routes!
3395
+ assert_raises(Padrino::Routing::UnrecognizedException) { @app.url(:page) }
3396
+ @app.get(:page, map: '/new') { 'new' }
3397
+ assert_equal '/new', @app.url(:page)
3398
+ end
3399
+
3400
+ it 'finds no route deleted from the routes' do
3401
+ mock_app do
3402
+ get(:gone, map: '/gone') { 'gone' }
3403
+ end
3404
+ assert_equal '/gone', @app.url(:gone)
3405
+ @app.router.routes.reject! { |route| route.name == :gone }
3406
+ assert_raises(Padrino::Routing::UnrecognizedException) { @app.url(:gone) }
3407
+ end
3408
+
3409
+ it 'finds a route pushed onto the routes by its new name after a rename' do
3410
+ mock_app do
3411
+ get(:index) { 'index' }
3412
+ end
3413
+ route = Padrino::PathRouter::Route.new('/page', :get, name: :old)
3414
+ @app.router.routes << route
3415
+ assert_equal '/page', @app.url(:old)
3416
+ route.name = :new
3417
+ assert_equal '/page', @app.url(:new)
3418
+ assert_raises(Padrino::Routing::UnrecognizedException) { @app.url(:old) }
3419
+ end
3420
+
3421
+ it 'keeps the router a route was added to when another router indexes it' do
3422
+ owner = Padrino::PathRouter.new
3423
+ route = owner.add(:get, '/shared', name: :old)
3424
+ owner.path(:old)
3425
+ other = Padrino::PathRouter.new
3426
+ other.routes << route
3427
+ other.path(:old)
3428
+ route.name = :new
3429
+ assert_equal '/shared', owner.path(:new)
3430
+ end
3431
+
3432
+ it 'finds a route added to the router with a name' do
3433
+ router = Padrino::PathRouter.new
3434
+ router.add(:get, '/one', name: :one)
3435
+ assert_equal '/one', router.path(:one)
3436
+ router.add(:get, '/two', name: :two)
3437
+ assert_equal '/two', router.path(:two)
3438
+ router.routes.pop
3439
+ router.add(:get, '/three', name: :three)
3440
+ assert_equal '/three', router.path(:three)
3441
+ end
3442
+
3443
+ it 'finds the routes added to the router after reset!' do
3444
+ router = Padrino::PathRouter.new
3445
+ router.add(:get, '/old', name: :page)
3446
+ assert_equal '/old', router.path(:page)
3447
+ router.reset!
3448
+ router.routes << Padrino::PathRouter::Route.new('/new', :get, name: :page)
3449
+ assert_equal '/new', router.path(:page)
3450
+ end
3451
+
3452
+ it 'picks the first route of a name in the order requests try them' do
3453
+ mock_app do
3454
+ get(:page, map: '/low', priority: :low) { 'low' }
3455
+ get(:page, map: '/normal') { 'normal' }
3456
+ end
3457
+ @app.url(:page)
3458
+ get '/normal'
3459
+ assert_equal '/normal', @app.url(:page)
3460
+ end
3461
+
3462
+ it 'picks the route of a name that takes the params given' do
3463
+ mock_app do
3464
+ get(:show, map: '/show/:id') { 'id' }
3465
+ get(:show, map: '/show/:id/:slug') { 'slug' }
3466
+ end
3467
+ 2.times do
3468
+ assert_equal '/show/1', @app.url(:show, id: 1)
3469
+ assert_equal '/show/1/x', @app.url(:show, id: 1, slug: 'x')
3470
+ assert_equal '/show/1?ref=a', @app.url(:show, id: 1, ref: 'a')
3471
+ end
3472
+ end
3473
+
3474
+ it 'expands the same url for each form of the params' do
3475
+ mock_app do
3476
+ get(:item, map: '/item/:id(.:format)?') { 'item' }
3477
+ get(:opt, map: '/opt(/:a(/:b))?') { 'opt' }
3478
+ end
3479
+ 2.times do
3480
+ assert_equal '/item/7', @app.url(:item, id: 7)
3481
+ assert_equal '/item/7', @app.url(:item, 'id' => 7)
3482
+ assert_equal '/item/-7', @app.url(:item, id: -7)
3483
+ assert_equal '/item/7.json', @app.url(:item, id: '7', format: :json)
3484
+ assert_equal '/item/a%20b', @app.url(:item, id: 'a b')
3485
+ assert_equal '/opt/1/2', @app.url(:opt, a: 1, b: 2)
3486
+ assert_equal '/opt?b=2', @app.url(:opt, b: 2)
3487
+ assert_equal '/opt?b=2', @app.url(:opt, a: nil, b: 2)
3488
+ assert_equal '/opt/1/2', @app.routes.find { |route| route.name == :opt }.path(a: 1, b: 2)
3489
+ assert_equal '/opt?b=2', @app.routes.find { |route| route.name == :opt }.path(a: nil, b: 2)
3490
+ end
3491
+ end
3492
+
3493
+ it 'expands values other than Strings, Symbols and Integers as before' do
3494
+ list = Class.new(Array)
3495
+ value = Class.new { def to_s; 'v1'; end }.new
3496
+ mock_app do
3497
+ get(:files, map: '/files/*') { 'files' }
3498
+ get(:opt, map: '/opt(/:a(/:b))?') { 'opt' }
3499
+ end
3500
+ 2.times do
3501
+ assert_equal '/files/x', @app.url(:files, splat: 'x')
3502
+ assert_equal '/files/x', @app.url(:files, splat: list['x'])
3503
+ assert_equal '/opt/1/2', @app.url(:opt, a: 1, b: 2)
3504
+ assert_equal '/opt/v1/2', @app.url(:opt, a: value, b: 2)
3505
+ assert_equal '/opt/false/2', @app.url(:opt, a: false, b: 2)
3506
+ end
3507
+ end
3508
+ end
3139
3509
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: padrino-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.3
4
+ version: 0.18.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Padrino Team
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2026-11-03 00:00:00.000000000 Z
14
+ date: 2026-11-06 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: ostruct
@@ -33,14 +33,14 @@ dependencies:
33
33
  requirements:
34
34
  - - '='
35
35
  - !ruby/object:Gem::Version
36
- version: 0.18.3
36
+ version: 0.18.5
37
37
  type: :runtime
38
38
  prerelease: false
39
39
  version_requirements: !ruby/object:Gem::Requirement
40
40
  requirements:
41
41
  - - '='
42
42
  - !ruby/object:Gem::Version
43
- version: 0.18.3
43
+ version: 0.18.5
44
44
  - !ruby/object:Gem::Dependency
45
45
  name: rackup
46
46
  requirement: !ruby/object:Gem::Requirement