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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -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 +52 -18
- 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 -2
- 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
|
@@ -22,32 +22,87 @@ module Grape
|
|
|
22
22
|
# X-Cascade header to alert Grape::Router to attempt the next matched
|
|
23
23
|
# route.
|
|
24
24
|
class Header < Base
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
# Accept headers answered up front besides the declared media types:
|
|
26
|
+
# +*/*+, what curl, Net::HTTP and most HTTP libraries send unless told
|
|
27
|
+
# otherwise, and no Accept header at all.
|
|
28
|
+
COMMON_ACCEPT_HEADERS = ['*/*', nil].freeze
|
|
29
|
+
|
|
30
|
+
# +MediaType.best_quality+ answers from the header and
|
|
31
|
+
# +available_media_types+ alone, and the latter is fixed once the
|
|
32
|
+
# middleware is built. Most requests send one of a handful of headers --
|
|
33
|
+
# a declared media type spelled as declared (+application/vnd.acme-v1+json+)
|
|
34
|
+
# or one of COMMON_ACCEPT_HEADERS -- so their answers are worked out
|
|
35
|
+
# once instead of re-running Rack's q-value match and a parse on every
|
|
36
|
+
# request. Any other header (a q-value list, another casing) is not a
|
|
37
|
+
# key and takes the full path, so a client cannot grow a table.
|
|
38
|
+
#
|
|
39
|
+
# Shared per vendor and list: every endpoint builds its own versioner,
|
|
40
|
+
# and all of an API's declare the same media types. The list in the
|
|
41
|
+
# key is a frozen copy, as the middleware's own list stays reachable
|
|
42
|
+
# through +available_media_types+.
|
|
43
|
+
class MediaTypeForAcceptCache < Grape::Util::Cache
|
|
44
|
+
def initialize
|
|
45
|
+
super
|
|
46
|
+
@cache = Hash.new do |h, (vendor, available_media_types)|
|
|
47
|
+
declared = available_media_types.map(&:-@).freeze
|
|
48
|
+
h[[vendor, declared].freeze] = [*declared, *COMMON_ACCEPT_HEADERS].to_h do |accept|
|
|
49
|
+
[accept, Grape::Util::MediaType.best_quality(accept, declared, vendor:)]
|
|
50
|
+
end.compact.freeze
|
|
51
|
+
end
|
|
34
52
|
end
|
|
35
53
|
end
|
|
36
54
|
|
|
55
|
+
def initialize(app, **options)
|
|
56
|
+
super
|
|
57
|
+
@media_type_for_accept = MediaTypeForAcceptCache[[vendor, available_media_types]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Base#call copies the middleware per request so #before can keep the
|
|
61
|
+
# env and the Accept header it scrubs in ivars. A request whose Accept
|
|
62
|
+
# header the table answers (see MediaTypeForAcceptCache) needs neither
|
|
63
|
+
# unless the API is strict, whose checks read the header itself: its
|
|
64
|
+
# media type is recorded and the app called from the one instance the
|
|
65
|
+
# stack built. Any other request goes through #before.
|
|
66
|
+
def call(env)
|
|
67
|
+
media_type = @media_type_for_accept[env['HTTP_ACCEPT']] if vendor && !strict
|
|
68
|
+
return super unless media_type
|
|
69
|
+
|
|
70
|
+
record_media_type(env, media_type)
|
|
71
|
+
app.call(env).to_a
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def before
|
|
75
|
+
match_best_quality_media_type! { |media_type| record_media_type(env, media_type) }
|
|
76
|
+
end
|
|
77
|
+
|
|
37
78
|
private
|
|
38
79
|
|
|
80
|
+
def record_media_type(env, media_type)
|
|
81
|
+
env.update(
|
|
82
|
+
Grape::Env::API_TYPE => media_type.type,
|
|
83
|
+
Grape::Env::API_SUBTYPE => media_type.subtype,
|
|
84
|
+
Grape::Env::API_VENDOR => media_type.vendor,
|
|
85
|
+
Grape::Env::API_VERSION => media_type.version,
|
|
86
|
+
Grape::Env::API_FORMAT => media_type.format
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
39
90
|
def match_best_quality_media_type!
|
|
40
91
|
return unless vendor
|
|
41
92
|
|
|
42
93
|
strict_header_checks!
|
|
43
|
-
|
|
94
|
+
# The table's keys are valid Strings, which scrubbing leaves alone, so
|
|
95
|
+
# it is read with the header as it came.
|
|
96
|
+
media_type = @media_type_for_accept[env['HTTP_ACCEPT']] || Grape::Util::MediaType.best_quality(accept_header, available_media_types, vendor:)
|
|
44
97
|
return yield media_type if media_type
|
|
45
98
|
|
|
46
99
|
fail!
|
|
47
100
|
end
|
|
48
101
|
|
|
102
|
+
# Scrubbed of any bytes its encoding does not allow: +blank?+,
|
|
103
|
+
# +downcase+ and Rack's q-value parsing all raise on one.
|
|
49
104
|
def accept_header
|
|
50
|
-
env['HTTP_ACCEPT']
|
|
105
|
+
@accept_header ||= try_scrub(env['HTTP_ACCEPT'])
|
|
51
106
|
end
|
|
52
107
|
|
|
53
108
|
def strict_header_checks!
|
|
@@ -64,7 +119,7 @@ module Grape
|
|
|
64
119
|
end
|
|
65
120
|
|
|
66
121
|
def version_and_vendor_check!
|
|
67
|
-
return if versions.
|
|
122
|
+
return if versions.nil? || versions.empty? || version_and_vendor?
|
|
68
123
|
|
|
69
124
|
invalid_accept_header!('API vendor or version not found.')
|
|
70
125
|
end
|
|
@@ -86,9 +141,9 @@ module Grape
|
|
|
86
141
|
end
|
|
87
142
|
|
|
88
143
|
def fail!
|
|
89
|
-
return if env[Grape::Env::GRAPE_ALLOWED_METHODS]
|
|
144
|
+
return if env[Grape::Env::GRAPE_ALLOWED_METHODS]
|
|
90
145
|
|
|
91
|
-
media_types = q_values_mime_types.map { |mime_type| Grape::Util::MediaType.parse(mime_type) }
|
|
146
|
+
media_types = q_values_mime_types.map { |mime_type| Grape::Util::MediaType.parse(mime_type, vendor:) }
|
|
92
147
|
vendor_not_found!(media_types) || version_not_found!(media_types)
|
|
93
148
|
end
|
|
94
149
|
|
|
@@ -99,7 +154,7 @@ module Grape
|
|
|
99
154
|
end
|
|
100
155
|
|
|
101
156
|
def version_not_found!(media_types)
|
|
102
|
-
return unless media_types.all? { |media_type| media_type&.version && versions
|
|
157
|
+
return unless media_types.all? { |media_type| media_type&.version && versions&.none? { |version| version.casecmp?(media_type.version) } }
|
|
103
158
|
|
|
104
159
|
invalid_version_header!('API version not found.')
|
|
105
160
|
end
|
|
@@ -8,19 +8,17 @@ module Grape
|
|
|
8
8
|
# request parameters for subsequent middleware and API.
|
|
9
9
|
# If the version substring does not match any potential initialized
|
|
10
10
|
# versions, a 404 error is thrown.
|
|
11
|
-
# If
|
|
12
|
-
# version will be used.
|
|
11
|
+
# If no version is passed, none is set and the first matching route answers.
|
|
13
12
|
#
|
|
14
13
|
# Example: For a uri path
|
|
15
14
|
# /resource?apiver=v1
|
|
16
15
|
#
|
|
17
|
-
# The following rack env
|
|
18
|
-
# '/resource':
|
|
16
|
+
# The following rack env variable is set:
|
|
19
17
|
#
|
|
20
18
|
# env['api.version'] => 'v1'
|
|
21
19
|
class Param < Base
|
|
22
20
|
def before
|
|
23
|
-
potential_version = query_params[parameter]
|
|
21
|
+
potential_version = try_scrub(query_params[parameter])
|
|
24
22
|
return if potential_version.blank?
|
|
25
23
|
|
|
26
24
|
version_not_found! unless potential_version_match?(potential_version)
|
|
@@ -4,28 +4,37 @@ module Grape
|
|
|
4
4
|
module Middleware
|
|
5
5
|
module Versioner
|
|
6
6
|
# This middleware sets various version related rack environment variables
|
|
7
|
-
# based on the uri path
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# based on the uri path, which it leaves unchanged. If the version
|
|
8
|
+
# substring does not match any potential initialized versions, a 404
|
|
9
|
+
# error is thrown.
|
|
10
10
|
#
|
|
11
11
|
# Example: For a uri path
|
|
12
12
|
# /v1/resource
|
|
13
13
|
#
|
|
14
|
-
# The following rack env
|
|
15
|
-
# '/resource':
|
|
14
|
+
# The following rack env variable is set:
|
|
16
15
|
#
|
|
17
16
|
# env['api.version'] => 'v1'
|
|
18
17
|
#
|
|
19
18
|
class Path < Base
|
|
20
19
|
def initialize(app, **options)
|
|
21
20
|
super
|
|
22
|
-
@prefixes = [mount_path, Grape::Util::PathNormalizer.call(prefix)].select { |p| p
|
|
21
|
+
@prefixes = [mount_path, Grape::Util::PathNormalizer.call(prefix)].select { |p| p && p != '/' }.freeze
|
|
23
22
|
end
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
# A request the router matched carries its version already (see
|
|
25
|
+
# #routed_version), so all there is to do is record it -- without the
|
|
26
|
+
# per-request copy of this middleware that Base#call makes to run
|
|
27
|
+
# #before and #after, neither of which has anything left to do.
|
|
28
|
+
def call(env)
|
|
29
|
+
routed = routed_version(env)
|
|
30
|
+
return super unless routed
|
|
31
|
+
|
|
32
|
+
env[Grape::Env::API_VERSION] = routed
|
|
33
|
+
app.call(env).to_a
|
|
34
|
+
end
|
|
28
35
|
|
|
36
|
+
# Reached only when #call found no routed version.
|
|
37
|
+
def before
|
|
29
38
|
path_info = env[Grape::Env::GRAPE_NORMALIZED_PATH] || Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
|
|
30
39
|
return if path_info == '/'
|
|
31
40
|
|
|
@@ -56,7 +65,7 @@ module Grape
|
|
|
56
65
|
# versioning one: Mustermann then reports the capture as every position
|
|
57
66
|
# it matched rather than the single segment recorded here, so that too
|
|
58
67
|
# is left to the path parse.
|
|
59
|
-
def routed_version
|
|
68
|
+
def routed_version(env)
|
|
60
69
|
version = env[Grape::Env::GRAPE_ROUTING_ARGS]&.[](:version)
|
|
61
70
|
version if version.is_a?(String)
|
|
62
71
|
end
|
|
@@ -79,7 +88,7 @@ module Grape
|
|
|
79
88
|
end
|
|
80
89
|
|
|
81
90
|
def declared_version?(candidate)
|
|
82
|
-
versions.
|
|
91
|
+
!versions.nil? && versions.include?(candidate)
|
|
83
92
|
end
|
|
84
93
|
end
|
|
85
94
|
end
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# :header - version from HTTP Accept header.
|
|
7
7
|
# :accept_version_header - version from HTTP Accept-Version header
|
|
8
8
|
# :path - version from uri. e.g. /v1/resource
|
|
9
|
-
# :param - version from uri query string, e.g. /
|
|
9
|
+
# :param - version from uri query string, e.g. /resource?apiver=v1
|
|
10
10
|
# See individual classes for details.
|
|
11
11
|
module Grape
|
|
12
12
|
module Middleware
|
data/lib/grape/mountable.rb
CHANGED
|
@@ -8,10 +8,8 @@ module Grape
|
|
|
8
8
|
# rather than duck-typing on an incidental internal method such as
|
|
9
9
|
# `inheritable_setting`.
|
|
10
10
|
#
|
|
11
|
-
# `Grape::API` and `Grape::API::Instance` are not related by inheritance
|
|
12
|
-
#
|
|
13
|
-
# the latter to `inheritable_setting`/`endpoints`), so there is no common
|
|
14
|
-
# ancestor to key an `is_a?` check on without this marker.
|
|
11
|
+
# `Grape::API` and `Grape::API::Instance` are not related by inheritance, so
|
|
12
|
+
# there is no common ancestor to key an `is_a?` check on without this marker.
|
|
15
13
|
#
|
|
16
14
|
# It answers identity only ("is this a Grape app?"). Capability checks that
|
|
17
15
|
# go on to call a stage-specific method — e.g. `respond_to?(:endpoints)`
|
data/lib/grape/namespace.rb
CHANGED
|
@@ -8,10 +8,9 @@ module Grape
|
|
|
8
8
|
attr_reader :space, :requirements, :options
|
|
9
9
|
|
|
10
10
|
# @param space [String] the name of this namespace
|
|
11
|
+
# @param requirements [Hash] param-regex pairs a request's path must meet
|
|
12
|
+
# for this namespace's endpoints to match
|
|
11
13
|
# @param options [Hash] options hash
|
|
12
|
-
# @option options :requirements [Hash] param-regex pairs, all of which must
|
|
13
|
-
# be met by a request's params for all endpoints in this namespace, or
|
|
14
|
-
# validation will fail and return a 422.
|
|
15
14
|
def initialize(space, requirements: nil, **options)
|
|
16
15
|
@space = space.to_s
|
|
17
16
|
@requirements = requirements
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grape
|
|
4
|
+
module Parser
|
|
5
|
+
# Parses a JSON Lines body into an Array of its values. Like a JSON body
|
|
6
|
+
# that is an Array, it is left in +env['api.request.body']+ rather than
|
|
7
|
+
# merged into +params+, which only takes a Hash.
|
|
8
|
+
#
|
|
9
|
+
# Blank lines are skipped, so a body may end with a newline or not, and
|
|
10
|
+
# its lines may end in CRLF.
|
|
11
|
+
class Jsonl < Base
|
|
12
|
+
def self.call(object, _env)
|
|
13
|
+
object.each_line.reject { |line| line.strip.empty? }.map { |line| ::Grape::Json.parse(line) }
|
|
14
|
+
rescue ::Grape::Json::ParseError
|
|
15
|
+
raise Grape::Exceptions::InvalidMessageBody.new('application/jsonl')
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/grape/request.rb
CHANGED
|
@@ -136,6 +136,10 @@ module Grape
|
|
|
136
136
|
["HTTP_#{header.upcase.tr('-', '_')}", header]
|
|
137
137
|
end.freeze
|
|
138
138
|
|
|
139
|
+
# Routing-args keys that are Grape's own, not request params.
|
|
140
|
+
GRAPE_OWNED_ROUTING_ARGS = %i[version route_info].freeze
|
|
141
|
+
ROUTE_INFO_ONLY = %i[route_info].freeze
|
|
142
|
+
|
|
139
143
|
alias rack_params params
|
|
140
144
|
alias rack_cookies cookies
|
|
141
145
|
|
|
@@ -174,12 +178,38 @@ module Grape
|
|
|
174
178
|
@params_builder ||= Grape::ParamsBuilder.params_builder_for(@build_params_with || Grape.config[:param_builder])
|
|
175
179
|
end
|
|
176
180
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
# Whether the request can have form params at all. Rack::Request#params is
|
|
182
|
+
# the query params merged with the form params, and a request that carries
|
|
183
|
+
# no body has none to merge -- but finding that out costs Rack a
|
|
184
|
+
# CONTENT_TYPE parse and an empty Hash written back into the env, on every
|
|
185
|
+
# request that reads params without sending a body. The env answers it
|
|
186
|
+
# first: form params already read, by Rack or by the formatter's parsers,
|
|
187
|
+
# are memoized under Rack's own key, and without them only a body could
|
|
188
|
+
# hold any. The method is deliberately not part of the test -- Rack reads
|
|
189
|
+
# a form body whatever the method says.
|
|
190
|
+
def form_params?
|
|
191
|
+
return true if env.key?(Rack::RACK_REQUEST_FORM_HASH)
|
|
192
|
+
|
|
193
|
+
env['CONTENT_LENGTH'].to_i.positive? || env['HTTP_TRANSFER_ENCODING'] == 'chunked'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# +self.GET+ is the query hash Rack memoized in the env, not the copy the
|
|
197
|
+
# merge in Rack::Request#params used to hand over. Every builder Grape
|
|
198
|
+
# ships constructs a new object, so there is nothing to copy; one that
|
|
199
|
+
# returns its argument would otherwise let the writes that follow --
|
|
200
|
+
# routing args, coercions, defaults -- reach the env.
|
|
201
|
+
def build_params
|
|
202
|
+
return params_builder.call(rack_params) if form_params?
|
|
181
203
|
|
|
182
|
-
|
|
204
|
+
query_hash = self.GET
|
|
205
|
+
built = params_builder.call(query_hash)
|
|
206
|
+
built.equal?(query_hash) ? built.dup : built
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def make_params
|
|
210
|
+
params = build_params
|
|
211
|
+
merge_routing_args!(params, env[Grape::Env::GRAPE_ROUTING_ARGS])
|
|
212
|
+
params
|
|
183
213
|
rescue *Grape::RACK_ERRORS
|
|
184
214
|
raise Grape::Exceptions::RequestError
|
|
185
215
|
end
|
|
@@ -193,11 +223,19 @@ module Grape
|
|
|
193
223
|
# (`route_param :version`, `get '/:version'`), and that capture belongs to
|
|
194
224
|
# the application. Dropping it unconditionally left `params[:version]` nil
|
|
195
225
|
# on a route that had matched, losing the segment silently.
|
|
196
|
-
|
|
226
|
+
#
|
|
227
|
+
# The rest are written into +params+ one by one rather than deep-merged
|
|
228
|
+
# from an +except+ copy, which was a Hash built per request only to be
|
|
229
|
+
# merged away. A capture is a String or an Array of them, never a Hash,
|
|
230
|
+
# so +deep_merge!+ had nothing to merge into: it replaced the value, as
|
|
231
|
+
# assigning it does.
|
|
232
|
+
def merge_routing_args!(params, routing_args)
|
|
197
233
|
return if routing_args.nil?
|
|
198
|
-
return routing_args.except(:version, :route_info) if grape_owns_version?(routing_args)
|
|
199
234
|
|
|
200
|
-
routing_args
|
|
235
|
+
grape_owned = grape_owns_version?(routing_args) ? GRAPE_OWNED_ROUTING_ARGS : ROUTE_INFO_ONLY
|
|
236
|
+
routing_args.each do |key, value|
|
|
237
|
+
params[key] = value unless grape_owned.include?(key)
|
|
238
|
+
end
|
|
201
239
|
end
|
|
202
240
|
|
|
203
241
|
# A route reports a +version+ only when the API declared one, which is the
|
|
@@ -66,10 +66,30 @@ module Grape
|
|
|
66
66
|
Regexp.new("(?<#{regexp_capture_index}>#{pattern_regexp})")
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# This route's named captures mapped to their group numbers in the router's
|
|
70
|
+
# union, or nil when a union match would not reproduce what Mustermann
|
|
71
|
+
# returns (see Route#params_for). Assigned in {#resolve_capture_group!},
|
|
72
|
+
# never at request time -- instances are shared across threads.
|
|
73
|
+
attr_reader :union_captures
|
|
74
|
+
|
|
75
|
+
# {#union_captures} renumbered for a union in which this route's own
|
|
76
|
+
# group is +group+ -- one of a bucket's, say. Every capture sits at a
|
|
77
|
+
# fixed offset from the route's own group, so the whole map shifts by
|
|
78
|
+
# the same number. Nil when the route has no reusable captures.
|
|
79
|
+
def union_captures_at(group)
|
|
80
|
+
return if @union_captures.nil?
|
|
81
|
+
|
|
82
|
+
shift = group - @regexp_capture_group
|
|
83
|
+
return @union_captures if shift.zero?
|
|
84
|
+
|
|
85
|
+
@union_captures.transform_values { |capture| capture + shift }.freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
69
88
|
# @api private
|
|
70
89
|
# @see #regexp_capture_group
|
|
71
90
|
def resolve_capture_group!(union_named_captures)
|
|
72
91
|
@regexp_capture_group = union_named_captures.fetch(regexp_capture_index).first
|
|
92
|
+
@union_captures = resolve_union_captures
|
|
73
93
|
end
|
|
74
94
|
|
|
75
95
|
class CaptureIndexCache < Grape::Util::Cache
|
|
@@ -80,6 +100,27 @@ module Grape
|
|
|
80
100
|
end
|
|
81
101
|
end
|
|
82
102
|
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# +to_regexp+'s wrapper is the group just before the pattern's own, and
|
|
107
|
+
# +Regexp.union+ shifts numbering only by the groups preceding a branch --
|
|
108
|
+
# what +regexp_capture_group+ counts. So the capture at position +n+ of the
|
|
109
|
+
# route's regexp is group +regexp_capture_group + n+ of the union.
|
|
110
|
+
def resolve_union_captures
|
|
111
|
+
own = @pattern.to_regexp.named_captures
|
|
112
|
+
return if own.empty?
|
|
113
|
+
|
|
114
|
+
# Mustermann returns an Array for a name matched at several positions.
|
|
115
|
+
return unless own.each_value.all? { |positions| positions.size == 1 }
|
|
116
|
+
|
|
117
|
+
# Rejects param converters and the always-Array +splat+/+captures+ names.
|
|
118
|
+
# Empty strings probe only what holds for every request; unescaping, the
|
|
119
|
+
# one thing that does not, is gated on the path in Route#params_for.
|
|
120
|
+
return unless @pattern.pattern.identity_params?(own.transform_values { '' })
|
|
121
|
+
|
|
122
|
+
own.to_h { |name, positions| [name.to_sym, @regexp_capture_group + positions.first] }
|
|
123
|
+
end
|
|
83
124
|
end
|
|
84
125
|
end
|
|
85
126
|
end
|
|
@@ -20,7 +20,7 @@ module Grape
|
|
|
20
20
|
param_name = scan(/\w+/)
|
|
21
21
|
# Integer params (declared via Grape's `params` option) match digits only;
|
|
22
22
|
# any other capture matches a single path segment (anything but / ? # .).
|
|
23
|
-
param_type = pattern
|
|
23
|
+
param_type = pattern.options.dig(:params, param_name, :type)
|
|
24
24
|
constraint = param_type == 'Integer' ? /\d/ : '[^/?#.]'
|
|
25
25
|
node(:capture, param_name, constraint:) { scan(/\w+/) }
|
|
26
26
|
end
|
data/lib/grape/router/pattern.rb
CHANGED
|
@@ -13,8 +13,8 @@ module Grape
|
|
|
13
13
|
def_delegators :to_regexp, :===
|
|
14
14
|
alias match? ===
|
|
15
15
|
|
|
16
|
-
# Build a Pattern from a raw path, namespace and
|
|
17
|
-
#
|
|
16
|
+
# Build a Pattern from a raw path, namespace and a PathSettings snapshot.
|
|
17
|
+
# {Path} owns the settings-aware assembly of +origin+/+suffix+;
|
|
18
18
|
# the Pattern itself stays value-based (see {#initialize}).
|
|
19
19
|
def self.build(path:, namespace:, settings:, anchor:, params:, version:, requirements:)
|
|
20
20
|
built_path = Path.new(path, namespace, settings)
|
|
@@ -27,9 +27,15 @@ module Grape
|
|
|
27
27
|
@version = version
|
|
28
28
|
@requirements = requirements
|
|
29
29
|
@path = PatternCache[[build_path_from_pattern(@origin, anchor), suffix]]
|
|
30
|
-
|
|
30
|
+
# +cache: false+: Mustermann memoizes what it matched in two
|
|
31
|
+
# ObjectSpace::WeakKeyMaps per pattern, one for +match+ and one for
|
|
32
|
+
# +peek_match+. Grape calls neither -- it matches through the compiled
|
|
33
|
+
# +to_regexp+, and the one method it does reach Mustermann for,
|
|
34
|
+
# +params+, reads the captures straight off that regexp without
|
|
35
|
+
# touching either map. Both were allocated per route and never read.
|
|
36
|
+
@pattern = MustermannPattern.new(@path, uri_decode: true, params:, capture: extract_capture(version, requirements), cache: false)
|
|
31
37
|
@to_regexp = @pattern.to_regexp
|
|
32
|
-
@captures =
|
|
38
|
+
@captures = !@to_regexp.names.empty?
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
# True when the compiled pattern has named captures to extract from a
|
|
@@ -83,7 +89,7 @@ module Grape
|
|
|
83
89
|
# +Regexp.escape+, which does accept a Symbol -- but a second raises
|
|
84
90
|
# TypeError.
|
|
85
91
|
def extract_capture(version, requirements)
|
|
86
|
-
return requirements if version.
|
|
92
|
+
return requirements if version.nil? || version.empty?
|
|
87
93
|
|
|
88
94
|
requirements.merge(version: Regexp.union(Array.wrap(version).map(&:to_s)))
|
|
89
95
|
end
|
data/lib/grape/router/route.rb
CHANGED
|
@@ -3,15 +3,11 @@
|
|
|
3
3
|
module Grape
|
|
4
4
|
class Router
|
|
5
5
|
class Route < BaseRoute
|
|
6
|
-
extend Forwardable
|
|
7
|
-
|
|
8
6
|
FORWARD_MATCH_METHOD = ->(input, pattern) { input.start_with?(pattern.origin) }
|
|
9
7
|
NON_FORWARD_MATCH_METHOD = ->(input, pattern) { pattern.match?(input) }
|
|
10
8
|
|
|
11
9
|
attr_reader :app, :request_method, :index
|
|
12
10
|
|
|
13
|
-
def_delegators :@app, :call
|
|
14
|
-
|
|
15
11
|
def initialize(endpoint, method, pattern, options, forward_match:, params: {}, **route_attributes)
|
|
16
12
|
super(pattern, options, **route_attributes)
|
|
17
13
|
@app = endpoint
|
|
@@ -31,8 +27,17 @@ module Grape
|
|
|
31
27
|
self
|
|
32
28
|
end
|
|
33
29
|
|
|
30
|
+
# Written out rather than delegated: the router calls it on every
|
|
31
|
+
# request, and a Forwardable delegator checks its target with +defined?+
|
|
32
|
+
# and forwards through +...+ each time.
|
|
33
|
+
def call(env)
|
|
34
|
+
@app.call(env)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The router hands over a normalized path, which always opens with a
|
|
38
|
+
# slash, so there is no whitespace-only one to treat as blank.
|
|
34
39
|
def match?(input)
|
|
35
|
-
return false if input.
|
|
40
|
+
return false if input.nil? || input.empty?
|
|
36
41
|
|
|
37
42
|
@match_function.call(input, pattern)
|
|
38
43
|
end
|
|
@@ -50,15 +55,21 @@ module Grape
|
|
|
50
55
|
# match entirely and answers nil — the same way {GreedyRoute#params_for}
|
|
51
56
|
# does, and what the router already coerces into the Hash it builds
|
|
52
57
|
# routing args in.
|
|
53
|
-
|
|
58
|
+
#
|
|
59
|
+
# +match+, when given, is the union MatchData the router matched this route
|
|
60
|
+
# with; its groups already hold the substrings a second match would produce
|
|
61
|
+
# (see {BaseRoute#union_captures}). +captures+ names those groups, since a
|
|
62
|
+
# route sits at one number in the router's union and at another in each
|
|
63
|
+
# bucket that holds it (see {BaseRoute#union_captures_at}); nil there means
|
|
64
|
+
# the match cannot stand in for Mustermann. Mustermann percent-decodes a
|
|
65
|
+
# captured value containing '%', and none can unless the path does -- so a
|
|
66
|
+
# '%'-free path reads the groups, anything else the full match below.
|
|
67
|
+
def params_for(input, match = nil, captures = union_captures)
|
|
54
68
|
return unless pattern.captures?
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
return unless parsed
|
|
70
|
+
return params_from_union(captures, match) if match && captures && !input.include?('%')
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
parsed.each { |name, value| params[name.to_sym] = tag_utf8!(value) unless value.nil? }
|
|
61
|
-
params
|
|
72
|
+
params_from_pattern(input)
|
|
62
73
|
end
|
|
63
74
|
|
|
64
75
|
protected
|
|
@@ -69,6 +80,26 @@ module Grape
|
|
|
69
80
|
|
|
70
81
|
private
|
|
71
82
|
|
|
83
|
+
# MatchData yields fresh, unfrozen Strings, as Mustermann's Hash does, so
|
|
84
|
+
# +tag_utf8!+ stays safe in place.
|
|
85
|
+
def params_from_union(captures, match)
|
|
86
|
+
params = {}
|
|
87
|
+
captures.each do |name, group|
|
|
88
|
+
value = match[group]
|
|
89
|
+
params[name] = tag_utf8!(value) unless value.nil?
|
|
90
|
+
end
|
|
91
|
+
params
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def params_from_pattern(input)
|
|
95
|
+
parsed = pattern.params(input)
|
|
96
|
+
return unless parsed
|
|
97
|
+
|
|
98
|
+
params = {}
|
|
99
|
+
parsed.each { |name, value| params[name.to_sym] = tag_utf8!(value) unless value.nil? }
|
|
100
|
+
params
|
|
101
|
+
end
|
|
102
|
+
|
|
72
103
|
# Mustermann decodes path captures out of +PATH_INFO+, which Rack hands us
|
|
73
104
|
# tagged ASCII-8BIT, so path params came back binary while Rack tags query
|
|
74
105
|
# and body params UTF-8. That split makes an API's own declarations
|