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
@@ -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
- def before
26
- match_best_quality_media_type! do |media_type|
27
- env.update(
28
- Grape::Env::API_TYPE => media_type.type,
29
- Grape::Env::API_SUBTYPE => media_type.subtype,
30
- Grape::Env::API_VENDOR => media_type.vendor,
31
- Grape::Env::API_VERSION => media_type.version,
32
- Grape::Env::API_FORMAT => media_type.format
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
- media_type = Grape::Util::MediaType.best_quality(accept_header, available_media_types)
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.blank? || version_and_vendor?
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].present?
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 && !versions.include?(media_type.version) }
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 the version substring is not passed the version (highest mounted)
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 variables are set and path is rewritten to
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 and removes the version substring from the uri
8
- # path. If the version substring does not match any potential initialized
9
- # versions, a 404 error is thrown.
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 variables are set and path is rewritten to
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.present? && p != '/' }.freeze
21
+ @prefixes = [mount_path, Grape::Util::PathNormalizer.call(prefix)].select { |p| p && p != '/' }.freeze
23
22
  end
24
23
 
25
- def before
26
- routed = routed_version
27
- return env[Grape::Env::API_VERSION] = routed if routed
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.present? && versions.include?(candidate)
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. /v1/resource?apiver=v1
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
@@ -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 and
12
- # do not even respond to the same methods (the former to `mount_instance`,
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)`
@@ -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
- def make_params
178
- params = params_builder.call(rack_params)
179
- filtered = routing_args_as_params(env[Grape::Env::GRAPE_ROUTING_ARGS])
180
- return params if filtered.blank?
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
- params.deep_merge!(filtered)
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
- def routing_args_as_params(routing_args)
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.except(:route_info)
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
@@ -22,7 +22,7 @@ module Grape
22
22
  nil
23
23
  end
24
24
 
25
- def params_for(_input)
25
+ def params_for(_input, _match = nil, _captures = nil)
26
26
  nil
27
27
  end
28
28
  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&.options&.dig(:params, param_name, :type)
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
@@ -57,7 +57,7 @@ module Grape
57
57
  end
58
58
 
59
59
  def uses_path_versioning?(settings)
60
- settings.version && settings.version_options&.using == :path
60
+ settings.version && settings.version_options.using == :path
61
61
  end
62
62
 
63
63
  def valid_part?(part)
@@ -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 the API's inheritable
17
- # settings. {Path} owns the settings-aware assembly of +origin+/+suffix+;
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
- @pattern = MustermannPattern.new(@path, uri_decode: true, params:, capture: extract_capture(version, requirements))
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 = @to_regexp.names.any?
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.blank?
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
@@ -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.blank?
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
- def params_for(input)
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
- parsed = pattern.params(input)
57
- return unless parsed
70
+ return params_from_union(captures, match) if match && captures && !input.include?('%')
58
71
 
59
- params = {}
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