grape 4.0.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -1
- data/README.md +195 -95
- data/UPGRADING.md +364 -0
- data/grape.gemspec +1 -1
- data/lib/grape/api/instance.rb +57 -30
- data/lib/grape/api.rb +50 -20
- data/lib/grape/content_types.rb +26 -9
- data/lib/grape/cookies.rb +1 -1
- data/lib/grape/declared_params_handler.rb +35 -7
- data/lib/grape/dsl/declared.rb +5 -4
- data/lib/grape/dsl/desc.rb +1 -2
- data/lib/grape/dsl/entity.rb +35 -19
- data/lib/grape/dsl/headers.rb +3 -3
- data/lib/grape/dsl/helpers.rb +5 -8
- data/lib/grape/dsl/inside_route.rb +33 -27
- data/lib/grape/dsl/logger.rb +10 -4
- data/lib/grape/dsl/middleware.rb +4 -9
- data/lib/grape/dsl/mounting.rb +149 -0
- data/lib/grape/dsl/parameters.rb +78 -104
- data/lib/grape/dsl/request_response.rb +35 -22
- data/lib/grape/dsl/routing.rb +17 -191
- data/lib/grape/dsl/settings.rb +1 -1
- data/lib/grape/dsl/version_options.rb +2 -2
- data/lib/grape/dsl/versioning.rb +98 -0
- data/lib/grape/endpoint/state.rb +15 -0
- data/lib/grape/endpoint.rb +229 -137
- data/lib/grape/error_formatter/base.rb +28 -13
- data/lib/grape/error_formatter/json.rb +3 -0
- data/lib/grape/error_formatter/jsonl.rb +15 -0
- data/lib/grape/error_formatter/xml.rb +3 -1
- data/lib/grape/exceptions/error_response.rb +23 -7
- data/lib/grape/exceptions/halt.rb +39 -0
- data/lib/grape/exceptions/validation.rb +9 -9
- data/lib/grape/exceptions/validation_errors.rb +12 -2
- data/lib/grape/formatter/jsonl.rb +34 -0
- data/lib/grape/formatter/markdown.rb +17 -0
- data/lib/grape/formatter/md.rb +11 -0
- data/lib/grape/locale/en.yml +1 -0
- data/lib/grape/middleware/auth/base.rb +16 -1
- data/lib/grape/middleware/auth/strategy_info.rb +2 -2
- data/lib/grape/middleware/base.rb +19 -13
- data/lib/grape/middleware/error.rb +133 -35
- data/lib/grape/middleware/formatter.rb +235 -94
- data/lib/grape/middleware/precomputed_content_types.rb +14 -2
- data/lib/grape/middleware/stack.rb +7 -8
- data/lib/grape/middleware/versioner/base.rb +26 -10
- data/lib/grape/middleware/versioner/header.rb +70 -15
- data/lib/grape/middleware/versioner/param.rb +3 -5
- data/lib/grape/middleware/versioner/path.rb +20 -11
- data/lib/grape/middleware/versioner.rb +1 -1
- data/lib/grape/mountable.rb +2 -4
- data/lib/grape/namespace.rb +2 -3
- data/lib/grape/parser/jsonl.rb +19 -0
- data/lib/grape/request.rb +46 -8
- data/lib/grape/router/base_route.rb +41 -0
- data/lib/grape/router/greedy_route.rb +1 -1
- data/lib/grape/router/mustermann_pattern.rb +1 -1
- data/lib/grape/router/pattern/path.rb +1 -1
- data/lib/grape/router/pattern.rb +11 -5
- data/lib/grape/router/route.rb +42 -11
- data/lib/grape/router/route_buckets.rb +246 -0
- data/lib/grape/router.rb +262 -32
- data/lib/grape/util/endpoint_configuration.rb +7 -1
- data/lib/grape/util/inheritable_setting.rb +80 -161
- data/lib/grape/util/lazy/value.rb +52 -11
- data/lib/grape/util/media_type.rb +38 -15
- data/lib/grape/util/path_normalizer.rb +7 -2
- data/lib/grape/util/registry.rb +9 -2
- data/lib/grape/util/scrub.rb +33 -0
- data/lib/grape/util/translation.rb +6 -6
- data/lib/grape/validations/attributes_iterator.rb +73 -37
- data/lib/grape/validations/coerce_options.rb +1 -1
- data/lib/grape/validations/params_documentation.rb +15 -7
- data/lib/grape/validations/params_scope.rb +176 -43
- data/lib/grape/validations/types/custom_type_coercer.rb +2 -3
- data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
- data/lib/grape/validations/types/dry_type_coercer.rb +20 -12
- data/lib/grape/validations/types/multiple_type_coercer.rb +1 -2
- data/lib/grape/validations/types/primitive_coercer.rb +17 -0
- data/lib/grape/validations/types/variant_collection_coercer.rb +24 -10
- data/lib/grape/validations/types.rb +12 -18
- data/lib/grape/validations/validations_spec.rb +53 -22
- data/lib/grape/validations/validators/allow_blank_validator.rb +2 -2
- data/lib/grape/validations/validators/as_validator.rb +2 -3
- data/lib/grape/validations/validators/base.rb +124 -25
- data/lib/grape/validations/validators/coerce_validator.rb +2 -3
- data/lib/grape/validations/validators/default_validator.rb +18 -2
- data/lib/grape/validations/validators/except_values_validator.rb +16 -3
- data/lib/grape/validations/validators/hash_elements_validator.rb +34 -0
- data/lib/grape/validations/validators/length_validator.rb +24 -16
- data/lib/grape/validations/validators/multiple_params_base.rb +0 -4
- data/lib/grape/validations/validators/values_validator.rb +18 -24
- data/lib/grape/version.rb +1 -1
- data/lib/grape.rb +3 -0
- metadata +18 -11
- data/lib/grape/util/lazy/value_array.rb +0 -21
- data/lib/grape/util/lazy/value_enumerable.rb +0 -31
- data/lib/grape/util/lazy/value_hash.rb +0 -21
- data/lib/grape/validations/multiple_attributes_iterator.rb +0 -13
- 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
|
|
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
|
-
|
|
51
|
-
transaction(input,
|
|
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
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
return response if
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|