grape 4.0.0 → 4.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -1
  3. data/README.md +195 -95
  4. data/UPGRADING.md +364 -0
  5. data/grape.gemspec +1 -1
  6. data/lib/grape/api/instance.rb +57 -30
  7. data/lib/grape/api.rb +52 -18
  8. data/lib/grape/content_types.rb +26 -9
  9. data/lib/grape/cookies.rb +1 -1
  10. data/lib/grape/declared_params_handler.rb +35 -7
  11. data/lib/grape/dsl/declared.rb +5 -4
  12. data/lib/grape/dsl/desc.rb +1 -2
  13. data/lib/grape/dsl/entity.rb +35 -19
  14. data/lib/grape/dsl/headers.rb +3 -3
  15. data/lib/grape/dsl/helpers.rb +5 -8
  16. data/lib/grape/dsl/inside_route.rb +33 -27
  17. data/lib/grape/dsl/logger.rb +10 -4
  18. data/lib/grape/dsl/middleware.rb +4 -9
  19. data/lib/grape/dsl/mounting.rb +149 -0
  20. data/lib/grape/dsl/parameters.rb +78 -104
  21. data/lib/grape/dsl/request_response.rb +35 -22
  22. data/lib/grape/dsl/routing.rb +17 -191
  23. data/lib/grape/dsl/settings.rb +1 -1
  24. data/lib/grape/dsl/version_options.rb +2 -2
  25. data/lib/grape/dsl/versioning.rb +98 -0
  26. data/lib/grape/endpoint/state.rb +15 -0
  27. data/lib/grape/endpoint.rb +229 -137
  28. data/lib/grape/error_formatter/base.rb +28 -13
  29. data/lib/grape/error_formatter/json.rb +3 -0
  30. data/lib/grape/error_formatter/jsonl.rb +15 -0
  31. data/lib/grape/error_formatter/xml.rb +3 -1
  32. data/lib/grape/exceptions/error_response.rb +23 -7
  33. data/lib/grape/exceptions/halt.rb +39 -0
  34. data/lib/grape/exceptions/validation.rb +9 -9
  35. data/lib/grape/exceptions/validation_errors.rb +12 -2
  36. data/lib/grape/formatter/jsonl.rb +34 -0
  37. data/lib/grape/formatter/markdown.rb +17 -0
  38. data/lib/grape/formatter/md.rb +11 -0
  39. data/lib/grape/locale/en.yml +1 -0
  40. data/lib/grape/middleware/auth/base.rb +16 -1
  41. data/lib/grape/middleware/auth/strategy_info.rb +2 -2
  42. data/lib/grape/middleware/base.rb +19 -13
  43. data/lib/grape/middleware/error.rb +133 -35
  44. data/lib/grape/middleware/formatter.rb +235 -94
  45. data/lib/grape/middleware/precomputed_content_types.rb +14 -2
  46. data/lib/grape/middleware/stack.rb +7 -8
  47. data/lib/grape/middleware/versioner/base.rb +26 -10
  48. data/lib/grape/middleware/versioner/header.rb +70 -15
  49. data/lib/grape/middleware/versioner/param.rb +3 -5
  50. data/lib/grape/middleware/versioner/path.rb +20 -11
  51. data/lib/grape/middleware/versioner.rb +1 -1
  52. data/lib/grape/mountable.rb +2 -4
  53. data/lib/grape/namespace.rb +2 -3
  54. data/lib/grape/parser/jsonl.rb +19 -0
  55. data/lib/grape/request.rb +46 -8
  56. data/lib/grape/router/base_route.rb +41 -0
  57. data/lib/grape/router/greedy_route.rb +1 -1
  58. data/lib/grape/router/mustermann_pattern.rb +1 -1
  59. data/lib/grape/router/pattern/path.rb +1 -1
  60. data/lib/grape/router/pattern.rb +11 -5
  61. data/lib/grape/router/route.rb +42 -11
  62. data/lib/grape/router/route_buckets.rb +246 -0
  63. data/lib/grape/router.rb +262 -32
  64. data/lib/grape/util/endpoint_configuration.rb +7 -1
  65. data/lib/grape/util/inheritable_setting.rb +80 -161
  66. data/lib/grape/util/lazy/value.rb +52 -11
  67. data/lib/grape/util/media_type.rb +38 -15
  68. data/lib/grape/util/path_normalizer.rb +7 -2
  69. data/lib/grape/util/registry.rb +9 -2
  70. data/lib/grape/util/scrub.rb +33 -0
  71. data/lib/grape/util/translation.rb +6 -6
  72. data/lib/grape/validations/attributes_iterator.rb +73 -37
  73. data/lib/grape/validations/coerce_options.rb +1 -1
  74. data/lib/grape/validations/params_documentation.rb +15 -7
  75. data/lib/grape/validations/params_scope.rb +176 -43
  76. data/lib/grape/validations/types/custom_type_coercer.rb +2 -3
  77. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  78. data/lib/grape/validations/types/dry_type_coercer.rb +20 -12
  79. data/lib/grape/validations/types/multiple_type_coercer.rb +1 -2
  80. data/lib/grape/validations/types/primitive_coercer.rb +17 -0
  81. data/lib/grape/validations/types/variant_collection_coercer.rb +24 -10
  82. data/lib/grape/validations/types.rb +12 -18
  83. data/lib/grape/validations/validations_spec.rb +53 -22
  84. data/lib/grape/validations/validators/allow_blank_validator.rb +2 -2
  85. data/lib/grape/validations/validators/as_validator.rb +2 -3
  86. data/lib/grape/validations/validators/base.rb +124 -25
  87. data/lib/grape/validations/validators/coerce_validator.rb +2 -3
  88. data/lib/grape/validations/validators/default_validator.rb +18 -2
  89. data/lib/grape/validations/validators/except_values_validator.rb +16 -3
  90. data/lib/grape/validations/validators/hash_elements_validator.rb +34 -0
  91. data/lib/grape/validations/validators/length_validator.rb +24 -16
  92. data/lib/grape/validations/validators/multiple_params_base.rb +0 -4
  93. data/lib/grape/validations/validators/values_validator.rb +18 -24
  94. data/lib/grape/version.rb +1 -2
  95. data/lib/grape.rb +3 -0
  96. metadata +18 -11
  97. data/lib/grape/util/lazy/value_array.rb +0 -21
  98. data/lib/grape/util/lazy/value_enumerable.rb +0 -31
  99. data/lib/grape/util/lazy/value_hash.rb +0 -21
  100. data/lib/grape/validations/multiple_attributes_iterator.rb +0 -13
  101. data/lib/grape/validations/single_attribute_iterator.rb +0 -28
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Router
5
+ # Narrows the routes a request is matched against to the ones that can
6
+ # match its path, by one literal path segment.
7
+ #
8
+ # A route that spells out a literal at segment +k+ of its path -- with
9
+ # every segment ahead of it a literal or a lone +:param+ that cannot span
10
+ # a '/', and no '/' after it that its pattern makes optional -- matches
11
+ # only paths whose segment +k+ is that literal. Grouping
12
+ # the routes by it, and keeping every route that cannot be grouped in each
13
+ # group, gives each group the routes that could match a path with that
14
+ # segment, still in registration order, so the first of them to match is
15
+ # the route the full union would have picked.
16
+ #
17
+ # Mustermann lets a literal character match its percent-encoding, and a
18
+ # route's pattern ends in \Z, which also matches before a trailing
19
+ # newline, so a path holding either is left to the full union.
20
+ #
21
+ # For example, a method with these routes, in registration order:
22
+ #
23
+ # /users /users/:id /users/:id/posts /posts /posts/:id
24
+ # /comments/:id /tags/:name /:slug
25
+ #
26
+ # is split by segment 1, which names every route but +/:slug+:
27
+ #
28
+ # "users" => /users, /users/:id, /users/:id/posts, /:slug
29
+ # "posts" => /posts, /posts/:id, /:slug
30
+ # "comments" => /comments/:id, /:slug
31
+ # "tags" => /tags/:name, /:slug
32
+ # fallback => /:slug
33
+ #
34
+ # A request for +/users/42+ is matched against the four routes under
35
+ # "users" instead of all eight, +/about+ names no bucket and is matched
36
+ # against the fallback, and +/users%2F42+ goes to the full union.
37
+ class RouteBuckets
38
+ # The routes one segment value can reach, with the number each one's
39
+ # group has in +union+: a route sits in several buckets, at a different
40
+ # position in each, so the numbers cannot be kept on the route.
41
+ class Bucket
42
+ def initialize(union, routes, groups, captures)
43
+ @union = union
44
+ @routes = routes
45
+ @groups = groups
46
+ @captures = captures
47
+ freeze
48
+ end
49
+
50
+ # Yields the route +input+ matched, the match, and that route's
51
+ # captures numbered for this union -- nil when a match cannot stand in
52
+ # for what Mustermann returns. Yields nothing when no route here
53
+ # matches.
54
+ def match(input)
55
+ @union&.match(input) do |m|
56
+ index = @groups.index { |group| m[group] }
57
+ yield(@routes[index], m, @captures[index])
58
+ end
59
+ end
60
+ end
61
+
62
+ NONE = Bucket.new(nil, [].freeze, [].freeze, [].freeze)
63
+ FULL_UNION_INPUT = /[%\n]/
64
+ # A segment ahead of the key: plain characters, or one +:param+.
65
+ LITERAL_SEGMENT = /\A[^:*?(){}|\\%]+\z/
66
+ CAPTURE_SEGMENT = /\A:(\w+)\z/
67
+ # The key itself is compared with a slice of the path cut at the first
68
+ # '.', so it holds no '.', and nothing a path could spell differently.
69
+ KEY_SEGMENT = /\A[^:*?(){}|\\%.+\s]+\z/
70
+ # Below this many routes the full union is already cheap to walk, and
71
+ # the extra unions would only lengthen boot.
72
+ MINIMUM_ROUTES = 8
73
+ private_constant :Bucket, :NONE, :FULL_UNION_INPUT, :LITERAL_SEGMENT, :CAPTURE_SEGMENT, :KEY_SEGMENT, :MINIMUM_ROUTES
74
+
75
+ class << self
76
+ # Buckets for +routes+ (with +regexps+, their union alternatives), or
77
+ # nil when no segment splits them well enough to be worth the unions.
78
+ # +unions+ is shared by a router's methods, so a bucket whose
79
+ # alternatives another method already compiled -- the HEAD routes
80
+ # mirroring GET's -- reuses that union.
81
+ #
82
+ # Every segment position is tried, and the one leaving the fewest
83
+ # routes to match wins. For the routes in the class example:
84
+ #
85
+ # position 1: users x3, posts x2, comments, tags, nil cost 1 + 3 = 4
86
+ # position 2: nil for all eight cost 8
87
+ # position 3: posts for /users/:id/posts, nil for 7 cost 7 + 1 = 8
88
+ #
89
+ # Position 1 wins, and a cost of 4 is within half of the 8 routes, so
90
+ # the buckets are built; at 5 they would not be.
91
+ def build(routes, regexps, unions)
92
+ return if routes.size < MINIMUM_ROUTES
93
+
94
+ position, keys = cheapest_position(routes)
95
+ return unless position && cost(keys) <= routes.size / 2
96
+
97
+ new(position, routes, regexps, keys, unions)
98
+ end
99
+
100
+ private
101
+
102
+ # The segment position leaving the fewest +routes+ to match, and the
103
+ # key of each route at it; nil when no route has a segment past the
104
+ # first.
105
+ def cheapest_position(routes)
106
+ keys_by_position = route_keys_by_position(routes)
107
+ candidates = (1...keys_by_position.size).map { |position| [position, keys_by_position[position]] }
108
+ candidates.min_by { |_, route_keys| cost(route_keys) }
109
+ end
110
+
111
+ # The key of each of +routes+ at every segment position, as far as
112
+ # the deepest of them goes (see #fill_keys).
113
+ def route_keys_by_position(routes)
114
+ segments = routes.map { |route| route.origin.split('/') }
115
+ keys_by_position = Array.new(segments.map(&:size).max) { Array.new(routes.size) }
116
+ routes.each_with_index { |route, index| fill_keys(keys_by_position, index, route, segments[index]) }
117
+ keys_by_position
118
+ end
119
+
120
+ # Writes into +keys_by_position+, at +index+, the literal +route+
121
+ # requires at each segment position, leaving nil where that segment
122
+ # cannot tell it apart. One walk over +segments+: a segment that does
123
+ # not span exactly one path segment ends it, as no position past that
124
+ # segment can tell the route apart either.
125
+ def fill_keys(keys_by_position, index, route, segments)
126
+ last = segments.size - 1
127
+ run_on = run_on_position(route, last)
128
+ position = 1
129
+ while position <= last
130
+ segment = segments[position]
131
+ keys_by_position[position][index] = segment if position != run_on && key?(segment)
132
+ return unless single_segment?(route, segment)
133
+
134
+ position += 1
135
+ end
136
+ end
137
+
138
+ def key?(segment)
139
+ segment.match?(KEY_SEGMENT) && segment.ascii_only?
140
+ end
141
+
142
+ # The position of the segment a path can run on from without a '/', or
143
+ # nil. Pattern#build_path_from_pattern makes the '/' ahead of a
144
+ # trailing +*path+ optional, and follows an unanchored route's last
145
+ # segment with '/?*path', so either segment can be the start of a
146
+ # longer one in the path.
147
+ def run_on_position(route, last)
148
+ return last - 1 if route.origin.end_with?('*path')
149
+
150
+ last unless route.anchor
151
+ end
152
+
153
+ def single_segment?(route, segment)
154
+ return true if segment.match?(LITERAL_SEGMENT)
155
+
156
+ name = segment[CAPTURE_SEGMENT, 1]
157
+ return false unless name
158
+ return Array(route.version).none? { |version| version.to_s.include?('/') } if name == 'version'
159
+
160
+ requirements = route.requirements
161
+ !(requirements && (requirements.key?(name.to_sym) || requirements.key?(name)))
162
+ end
163
+
164
+ # The most routes one request can still be matched against.
165
+ def cost(keys)
166
+ keys.count(nil) + (keys.compact.tally.values.max || 0)
167
+ end
168
+ end
169
+
170
+ def initialize(position, routes, regexps, keys, unions)
171
+ @position = position
172
+ ungrouped, grouped = split_indices(keys)
173
+ # Every bucket holds the routes no segment tells apart, so none can be
174
+ # built before the whole of +ungrouped+ is known.
175
+ grouped.transform_values! { |indices| bucket(routes, regexps, (indices + ungrouped).sort!, unions) }
176
+ @buckets = grouped.freeze
177
+ @fallback = ungrouped.empty? ? NONE : bucket(routes, regexps, ungrouped, unions)
178
+ freeze
179
+ end
180
+
181
+ # The bucket for +input+, or nil when it has to go through the full union.
182
+ # With the class example's buckets:
183
+ #
184
+ # /users/42, /users.json, /users/42.json => "users"
185
+ # /about, / => the fallback
186
+ # /users%2F42, "/users\n" => nil
187
+ def bucket_for(input)
188
+ return if input.match?(FULL_UNION_INPUT)
189
+
190
+ key = segment_key(input)
191
+ (key && @buckets[key]) || @fallback
192
+ end
193
+
194
+ private
195
+
196
+ # The indices of the routes no key names, and the indices each key does
197
+ # name, in one pass over +keys+ -- +keys[n]+ being the key of the route
198
+ # at +n+, or nil when no segment tells that route apart.
199
+ def split_indices(keys)
200
+ ungrouped = []
201
+ grouped = {}
202
+ keys.each_with_index do |key, index|
203
+ next ungrouped << index if key.nil?
204
+
205
+ (grouped[key] ||= []) << index
206
+ end
207
+ [ungrouped, grouped]
208
+ end
209
+
210
+ def bucket(routes, regexps, indices, unions)
211
+ alternatives = regexps.values_at(*indices).freeze
212
+ members = routes.values_at(*indices).freeze
213
+ union, groups = unions[alternatives] ||= compile_union(alternatives, members)
214
+ # A member sits at a different group here than it does in the router's
215
+ # union, which is what its own captures are numbered for.
216
+ captures = members.map.with_index { |route, index| route.union_captures_at(groups[index]) }.freeze
217
+ Bucket.new(union, members, groups, captures)
218
+ end
219
+
220
+ # The union of +alternatives+ and the group each of +members+ has in it.
221
+ # Each alternative names its group after its route's position, so equal
222
+ # alternatives spell out the same union with the same groups, whichever
223
+ # method's routes they came from.
224
+ def compile_union(alternatives, members)
225
+ union = Regexp.union(alternatives)
226
+ named_captures = union.named_captures
227
+ [union, members.map { |route| named_captures.fetch(route.regexp_capture_index).first }.freeze].freeze
228
+ end
229
+
230
+ # Segment +@position+ of +input+, cut at its first '.'.
231
+ def segment_key(input)
232
+ start = 0
233
+ @position.times do
234
+ start = input.index('/', start)
235
+ return unless start
236
+
237
+ start += 1
238
+ end
239
+ stop = input.index('/', start) || input.length
240
+ dot = input.index('.', start)
241
+ stop = dot if dot && dot < stop
242
+ input[start, stop - start]
243
+ end
244
+ end
245
+ end
246
+ end
data/lib/grape/router.rb CHANGED
@@ -6,30 +6,29 @@ module Grape
6
6
  @neutral_map = []
7
7
  @neutral_regexes = []
8
8
  # Plain hashes with no auto-vivifying default: a lookup for an HTTP method
9
- # that has no routes must not insert a key. `compile!` freezes both maps,
9
+ # that has no routes must not insert a key. `compile!` freezes them all,
10
10
  # so request-time reads never mutate shared state (see #match? / #rotation).
11
11
  @map = {}
12
12
  @optimized_map = {}
13
+ # Per HTTP method, the greedy routes still worth trying once that method's
14
+ # own routes have missed a path (see #compile_neighbour_map).
15
+ @neighbour_map = {}
13
16
  end
14
17
 
15
18
  def compile!
16
19
  return if @compiled
17
20
 
18
21
  @union = resolve_capture_groups(Regexp.union(@neutral_regexes), @neutral_map)
22
+ compile_optimized_map
23
+ compile_neighbour_map
19
24
  @neutral_regexes = nil
20
- # Compiled from the routes actually registered rather than from
21
- # Grape::HTTP_SUPPORTED_METHODS. A route declared with any other verb
22
- # (`route :purge, '/cache'`) is accepted at definition time and is
23
- # already collected into the resource's Allow header, so skipping it here
24
- # left it in @map but out of @optimized_map — the only map #match? reads.
25
- # The resource then advertised a method that could never be matched and
26
- # answered every request for it with 405.
27
- @map.each do |method, routes|
28
- optimized_map = routes.map.with_index { |route, index| route.to_regexp(index) }
29
- @optimized_map[method] = resolve_capture_groups(Regexp.union(optimized_map), routes)
30
- end
31
25
  @map.freeze
32
26
  @optimized_map.freeze
27
+ @neighbour_map.freeze
28
+ # A route whose path holds a non-ASCII literal compiles to a UTF-8
29
+ # regexp, which raises when matched against a binary string holding
30
+ # non-ASCII bytes (see #utf8_path).
31
+ @utf8_patterns = @union.fixed_encoding? || @optimized_map.each_value.any? { |union, _| union.fixed_encoding? }
33
32
  @compiled = true
34
33
  end
35
34
 
@@ -43,17 +42,28 @@ module Grape
43
42
  end
44
43
 
45
44
  def call(env)
45
+ request_method = env[Rack::REQUEST_METHOD]
46
46
  with_optimization do
47
+ input = Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
48
+ if @utf8_patterns && !input.ascii_only?
49
+ input = utf8_path(input)
50
+ next unless input
51
+ end
52
+
47
53
  # Published on the env so the middleware the matched route runs -- the
48
54
  # path versioner above all -- reads the path this routed on instead of
49
55
  # normalizing PATH_INFO a second time.
50
- input = env[Grape::Env::GRAPE_NORMALIZED_PATH] = Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
51
- transaction(input, env[Rack::REQUEST_METHOD], env)
56
+ env[Grape::Env::GRAPE_NORMALIZED_PATH] = input
57
+ transaction(input, request_method, env)
52
58
  end
53
59
  end
54
60
 
55
61
  def recognize_path(input)
56
- any = with_optimization { greedy_match?(input) }
62
+ any = with_optimization do
63
+ input = utf8_path(input) if @utf8_patterns && !input.ascii_only?
64
+ # A path that is not valid UTF-8 comes back nil and matches nothing.
65
+ greedy_match?(input)
66
+ end
57
67
  return if any == default_response
58
68
 
59
69
  any.endpoint
@@ -64,20 +74,74 @@ module Grape
64
74
 
65
75
  private
66
76
 
77
+ # +path+ tagged UTF-8 when its bytes are UTF-8, or nil when they are not.
78
+ #
79
+ # Rack hands PATH_INFO over binary, and a binary string holding non-ASCII
80
+ # bytes raises Encoding::CompatibilityError when matched against a UTF-8
81
+ # regexp -- so once one route held a non-ASCII literal (`/café/:id`),
82
+ # every path carrying raw non-ASCII bytes raised, whichever route it was
83
+ # meant for. Tagged UTF-8, such a path routes the way its percent-encoded
84
+ # spelling does. Bytes that are not UTF-8 cannot be matched against these
85
+ # routes at all, so the router answers them as a path nothing matched.
86
+ #
87
+ # A copy, so PATH_INFO itself stays binary as Rack requires.
88
+ def utf8_path(path)
89
+ utf8 = String.new(path, encoding: Encoding::UTF_8)
90
+ utf8 if utf8.valid_encoding?
91
+ end
92
+
67
93
  # Resolve +input+ against the compiled routes, in priority order:
68
94
  #
69
95
  # 1. the routes registered for +method+ — the compiled-union match first,
70
96
  # then, when that route cascades, its siblings (see #rotation);
71
97
  # 2. the ANY (+'*'+) routes;
72
- # 3. the greedy neighbour, which answers auto-OPTIONS and 405.
98
+ # 3. the greedy neighbour, which answers 405 — and auto-OPTIONS, ahead of
99
+ # the ANY routes.
73
100
  #
74
101
  # Returns nil when nothing answered, leaving the caller to 404. A response
75
102
  # that cascades is never final: it is returned only once every later
76
103
  # candidate has declined too, so the caller (or a mounting app upstream)
77
104
  # can keep looking.
78
105
  def transaction(input, method, env)
79
- exact_route = match?(input, method)
80
- response = process_route(exact_route, input, env) if exact_route
106
+ union, routes, buckets, static_routes = @optimized_map[method]
107
+ if (static = static_routes&.[](input))
108
+ exact_route, captures = static
109
+ settle(input, method, env, process_static_route(exact_route, captures, env), exact_route)
110
+ elsif (bucket = buckets&.bucket_for(input))
111
+ bucket_transaction(bucket, input, method, env)
112
+ else
113
+ union_transaction(union, routes, input, method, env)
114
+ end
115
+ end
116
+
117
+ # #transaction for a path a bucket narrowed the routes for. A bucket
118
+ # numbers its groups its own way, so it hands over the captures numbered
119
+ # for its own union along with the match.
120
+ def bucket_transaction(bucket, input, method, env)
121
+ exact_route = nil
122
+ response = bucket.match(input) do |route, m, captures|
123
+ exact_route = route
124
+ process_route(route, input, env, m, captures)
125
+ end
126
+ settle(input, method, env, response, exact_route)
127
+ end
128
+
129
+ # #transaction for a path matched against all the routes for +method+.
130
+ # Matched here rather than through #match? so the MatchData survives: the
131
+ # route's path captures are groups of it (see Route#params_for).
132
+ def union_transaction(union, routes, input, method, env)
133
+ exact_route = nil
134
+ response = union&.match(input) do |m|
135
+ exact_route = matched_route(routes, m)
136
+ process_route(exact_route, input, env, m, exact_route.union_captures)
137
+ end
138
+ settle(input, method, env, response, exact_route)
139
+ end
140
+
141
+ # What #transaction answers once +exact_route+ answered with +response+:
142
+ # that response, unless it cascades. A route that declined (+response+
143
+ # cascades), or none matching (+response+ is nil), leaves the rest to try.
144
+ def settle(input, method, env, response, exact_route)
81
145
  return response if halt?(response)
82
146
 
83
147
  # A cascading route has only declined this request. Its siblings — the
@@ -91,34 +155,49 @@ module Grape
91
155
  return response if response && !cascade?(response)
92
156
  end
93
157
 
94
- last_neighbor_route = greedy_match?(input)
158
+ neighbours(input, method, env, response, cascaded)
159
+ end
160
+
161
+ # The ANY ('*') routes and the greedy neighbour, tried once the routes for
162
+ # +method+ have declined. +cascaded+ says whether any of them matched: the
163
+ # auto-OPTIONS and 405 answers are only right when none did.
164
+ def neighbours(input, method, env, response, cascaded)
165
+ # Only ever read while nothing for +method+ has matched, so once a route
166
+ # has -- and cascaded -- there is no neighbour to look for.
167
+ last_neighbor_route = neighbour_match?(input, method) unless cascaded
95
168
 
96
169
  # If last_neighbor_route exists and request method is OPTIONS,
97
170
  # return response by using #include_allow_header.
98
- return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && method == Rack::OPTIONS && last_neighbor_route
171
+ return process_route(last_neighbor_route, input, env, include_allow_header: true) if last_neighbor_route && method == Rack::OPTIONS
99
172
 
100
173
  star_route = match?(input, '*')
101
-
102
174
  if star_route
103
- close_body(response) if response # superseded by the ANY route
104
- response = process_route(star_route, input, env)
105
- return response if halt?(response)
106
-
107
- cascaded ||= !response.nil?
175
+ response = process_star_route(star_route, input, env, response)
176
+ # Halting or cascading, the ANY route's answer is the one returned.
177
+ return response if response
108
178
  end
109
179
 
110
- return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && last_neighbor_route
180
+ return process_route(last_neighbor_route, input, env, include_allow_header: true) if last_neighbor_route
111
181
 
112
182
  response
113
183
  end
114
184
 
185
+ # The ANY route's answer, superseding +response+. A cascading one has its
186
+ # body closed, as #halt? closes it, and is still what #neighbours returns.
187
+ def process_star_route(star_route, input, env, response)
188
+ close_body(response) if response
189
+ star_response = process_route(star_route, input, env)
190
+ close_body(star_response) if cascade?(star_response)
191
+ star_response
192
+ end
193
+
115
194
  # The routes registered for +method+ other than +exact_route+, tried in
116
195
  # registration order until one answers without cascading. Returns the last
117
196
  # response processed — a cascading one when every sibling declined, so the
118
197
  # caller can hand it back — or nil when no sibling matched.
119
198
  def rotation(input, method, env, exact_route)
120
199
  response = nil
121
- @map[method]&.each do |route|
200
+ @map[method].each do |route|
122
201
  next if exact_route == route
123
202
  next unless route.match?(input)
124
203
 
@@ -151,16 +230,81 @@ module Grape
151
230
  # Routing args are rebuilt for every attempt: when a route cascades
152
231
  # (X-Cascade pass), the next candidate must not observe the previous
153
232
  # attempt's +route_info+ or path captures.
154
- def process_route(route, input, env, include_allow_header: false)
233
+ def process_route(route, input, env, match = nil, captures = nil, include_allow_header: false)
155
234
  # The path captures are the hash: +route_info+ is written into them
156
- # rather than merged in from a second one.
157
- routing_args = route.params_for(input) || {}
235
+ # rather than merged in from a second one. +captures+ names the groups of
236
+ # +match+ that hold them: the router's union numbers them one way, a
237
+ # bucket's another.
238
+ routing_args = route.params_for(input, match, captures) || {}
158
239
  routing_args[:route_info] = route
159
240
  env[Grape::Env::GRAPE_ROUTING_ARGS] = routing_args
160
241
  env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.allow_header if include_allow_header
161
242
  route.call(env)
162
243
  end
163
244
 
245
+ # {#process_route} for an entry of {#static_routes_for}. Its captures were
246
+ # read when the table was built; each request gets its own copy of each,
247
+ # the way it gets fresh strings out of a match.
248
+ def process_static_route(route, captures, env)
249
+ routing_args = captures&.transform_values(&:dup) || {}
250
+ routing_args[:route_info] = route
251
+ env[Grape::Env::GRAPE_ROUTING_ARGS] = routing_args
252
+ route.call(env)
253
+ end
254
+
255
+ # A request for a path a route spells out in full -- no param in it, its
256
+ # version filled in -- is answered from this table rather than by the
257
+ # union. The union tries its alternatives one after another, and finding
258
+ # which one matched scans every route ahead of it, so a request costs more
259
+ # the later its route was registered; a Hash lookup does not.
260
+ #
261
+ # Each path is resolved here the way a request for it is, through the
262
+ # union and the scan, so an entry holds whichever route answers it -- an
263
+ # earlier route with a param where the path has a segment included -- and
264
+ # the captures it hands over. Only paths worked out from the routes are
265
+ # keys, so a client cannot grow the table, and a path that is not in it,
266
+ # or whose captures are not all plain Strings, is matched as before.
267
+ def static_routes_for(union, routes)
268
+ routes.each_with_object({}) do |route, table|
269
+ static_paths(route).each do |path|
270
+ next if table.key?(path)
271
+
272
+ entry = static_route_entry(union, routes, path)
273
+ table[path] = entry if entry
274
+ end
275
+ end.freeze
276
+ end
277
+
278
+ # The route a request for +path+ resolves to and the captures it hands
279
+ # over, frozen for every such request to share; nil when a capture is not
280
+ # a String.
281
+ def static_route_entry(union, routes, path)
282
+ union.match(path) do |m|
283
+ matched = matched_route(routes, m)
284
+ captures = matched.params_for(path, m)
285
+ # Only a String is frozen whole below; any other capture would be
286
+ # shared across requests with its insides open to change.
287
+ next unless captures.nil? || captures.each_value.all?(String)
288
+
289
+ [matched, captures.presence&.each_value(&:freeze)&.freeze].freeze
290
+ end
291
+ end
292
+
293
+ STATIC_PATH_EXCLUDED = /[:*?(){}|\\%]/
294
+ private_constant :STATIC_PATH_EXCLUDED
295
+
296
+ # The request paths +route+ spells out in full: its origin, with each
297
+ # version it declares filled in under path versioning. A path still
298
+ # holding pattern syntax or a '%', or one the normalizer would rewrite, is
299
+ # left out, since no request routes on it spelled that way. This only
300
+ # picks the paths worth resolving; what answers them is up to the union.
301
+ def static_paths(route)
302
+ origin = route.origin
303
+ version_segment = Grape::Router::Pattern::Path::VERSION_SEGMENT
304
+ paths = origin.include?(version_segment) ? Array(route.version).map { |version| origin.sub(version_segment, version.to_s) } : [origin]
305
+ paths.select { |path| !path.match?(STATIC_PATH_EXCLUDED) && Grape::Util::PathNormalizer.call(path) == path }
306
+ end
307
+
164
308
  # Tells each route the number of the group it ended up as in +union+. The
165
309
  # numbering is a property of the union rather than of the route's own
166
310
  # pattern -- every route ahead of it contributes however many groups its
@@ -177,6 +321,8 @@ module Grape
177
321
  yield || default_response
178
322
  end
179
323
 
324
+ # The 404 answered when nothing matched. The Rack::Head the API wraps
325
+ # around the router strips its body from a HEAD request.
180
326
  def default_response
181
327
  [404, DEFAULT_RESPONSE_HEADERS.dup, DEFAULT_RESPONSE_BODY.dup]
182
328
  end
@@ -187,13 +333,97 @@ module Grape
187
333
  # name sends MatchData through the pattern's name table on every lookup,
188
334
  # a number indexes the match region directly.
189
335
  def match?(input, method)
190
- @optimized_map[method]&.match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_group] } }
336
+ union, routes = @optimized_map[method]
337
+ union&.match(input) { |m| routes.detect { |route| m[route.regexp_capture_group] } }
191
338
  end
192
339
 
193
340
  def greedy_match?(input)
194
341
  @union.match(input) { |m| @neutral_map.detect { |route| m[route.regexp_capture_group] } }
195
342
  end
196
343
 
344
+ # The route of +routes+ whose alternative of their union produced +match+,
345
+ # found as #match? finds it.
346
+ def matched_route(routes, match)
347
+ routes.detect { |route| match[route.regexp_capture_group] }
348
+ end
349
+
350
+ # The greedy route for +input+ once the routes for +method+ have all missed
351
+ # it, from the ones #compile_neighbour_map left to try. A method with no
352
+ # routes of its own covers no path, so it still walks the whole of @union.
353
+ def neighbour_match?(input, method)
354
+ return greedy_match?(input) unless @map.key?(method)
355
+
356
+ union, routes, groups = @neighbour_map[method]
357
+ union&.match(input) do |m|
358
+ routes[groups.index { |group| m[group] }]
359
+ end
360
+ end
361
+
362
+ # Compiled from the routes actually registered rather than from
363
+ # Grape::HTTP_SUPPORTED_METHODS. A route declared with any other verb
364
+ # (`route :purge, '/cache'`) is accepted at definition time and is
365
+ # already collected into the resource's Allow header, so skipping it here
366
+ # left it in @map but out of @optimized_map — the only map #match? reads.
367
+ # The resource then advertised a method that could never be matched and
368
+ # answered every request for it with 405.
369
+ def compile_optimized_map
370
+ # Shared by the methods below: HEAD's routes mirror GET's, so their
371
+ # buckets compile to the same unions.
372
+ bucket_unions = {}
373
+ @map.each do |method, routes|
374
+ @optimized_map[method] = optimized_routes(routes, bucket_unions)
375
+ end
376
+ end
377
+
378
+ # The union of +routes+, paired with the routes it was built from, so a
379
+ # request reads everything it can be matched with through a single
380
+ # lookup: the buckets that narrow those routes by a path segment (nil
381
+ # when no segment splits them well), and the table of the paths they
382
+ # spell out in full -- nil when none does, so a request for their method
383
+ # goes straight to the union rather than missing a lookup first.
384
+ def optimized_routes(routes, bucket_unions)
385
+ regexps = routes.map.with_index { |route, index| route.to_regexp(index) }
386
+ union = resolve_capture_groups(Regexp.union(regexps), routes)
387
+ static_routes = static_routes_for(union, routes)
388
+ [union, routes, RouteBuckets.build(routes, regexps, bucket_unions), (static_routes unless static_routes.empty?)].freeze
389
+ end
390
+
391
+ # After a miss, #neighbours looks for a greedy route to answer 405 with.
392
+ # Walking the whole of @union for it would cost a 404 a second walk over
393
+ # every path, and most of that walk cannot succeed: a greedy route shares
394
+ # its pattern with the routes it was collected from (see
395
+ # API::Instance#collect_route_config_per_pattern), so once the union for
396
+ # +method+ has missed a path, the greedy route of every path +method+ has a
397
+ # route on has missed it too. What is left to try is the paths +method+ has
398
+ # no route on -- in an API where every path answers GET, none at all for a
399
+ # GET.
400
+ #
401
+ # Built from the same members as @union, in the same order, so a path
402
+ # resolves to the same greedy route either way. Methods that leave the same
403
+ # paths uncovered -- PUT and DELETE on a member, say -- share one union.
404
+ def compile_neighbour_map
405
+ unions = {}
406
+ @map.each do |method, routes|
407
+ covered = routes.to_set(&:pattern_regexp)
408
+ uncovered = @neutral_map.each_index.reject { |index| covered.include?(@neutral_map[index].pattern_regexp) }
409
+ @neighbour_map[method] = unions[uncovered] ||= neighbour_union(uncovered) unless uncovered.empty?
410
+ end
411
+ end
412
+
413
+ # The union of the greedy routes at +indices+ of @neutral_map, those
414
+ # routes, and the group each of them occupies in the union: a subset of
415
+ # @union numbers its groups differently, so the ones
416
+ # #resolve_capture_groups recorded do not apply. Routes and groups are
417
+ # parallel Arrays for #neighbour_match? to walk with Array#index, as
418
+ # Enumerable#find over a Hash allocates on every call.
419
+ def neighbour_union(indices)
420
+ union = Regexp.union(@neutral_regexes.values_at(*indices))
421
+ named_captures = union.named_captures
422
+ routes = @neutral_map.values_at(*indices).freeze
423
+ groups = routes.map { |route| named_captures.fetch(route.regexp_capture_index).first }.freeze
424
+ [union, routes, groups].freeze
425
+ end
426
+
197
427
  def cascade?(response)
198
428
  response && response[1]['X-Cascade'] == 'pass'
199
429
  end
@@ -2,7 +2,13 @@
2
2
 
3
3
  module Grape
4
4
  module Util
5
- class EndpointConfiguration < Lazy::ValueHash
5
+ # The +with:+ Hash a mount supplies, read as a {Grape::Util::Lazy::Value} so
6
+ # that reads taken before the mount happened can be replayed against it
7
+ # afterwards. Keys are indifferent at every depth.
8
+ class EndpointConfiguration < Lazy::Value
9
+ def initialize(config)
10
+ super(config.with_indifferent_access)
11
+ end
6
12
  end
7
13
  end
8
14
  end