grape 3.3.4 → 4.0.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/CONTRIBUTING.md +16 -0
  4. data/README.md +208 -8
  5. data/UPGRADING.md +756 -6
  6. data/lib/grape/api/instance.rb +40 -33
  7. data/lib/grape/content_types.rb +47 -1
  8. data/lib/grape/dry_types.rb +1 -1
  9. data/lib/grape/dsl/callbacks.rb +3 -9
  10. data/lib/grape/dsl/declared.rb +4 -4
  11. data/lib/grape/dsl/desc.rb +18 -8
  12. data/lib/grape/dsl/entity.rb +37 -19
  13. data/lib/grape/dsl/helpers.rb +3 -3
  14. data/lib/grape/dsl/inside_route.rb +20 -8
  15. data/lib/grape/dsl/middleware.rb +3 -3
  16. data/lib/grape/dsl/parameters.rb +54 -28
  17. data/lib/grape/dsl/request_response.rb +37 -39
  18. data/lib/grape/dsl/rescue_options.rb +6 -5
  19. data/lib/grape/dsl/routing.rb +116 -49
  20. data/lib/grape/dsl/settings.rb +1 -1
  21. data/lib/grape/dsl/validations.rb +3 -3
  22. data/lib/grape/dsl/version_options.rb +3 -3
  23. data/lib/grape/endpoint/options.rb +13 -7
  24. data/lib/grape/endpoint.rb +141 -118
  25. data/lib/grape/env.rb +7 -0
  26. data/lib/grape/error_formatter/base.rb +6 -5
  27. data/lib/grape/error_formatter.rb +6 -2
  28. data/lib/grape/exceptions/error_response.rb +4 -1
  29. data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
  30. data/lib/grape/exceptions/validation.rb +9 -8
  31. data/lib/grape/formatter/json.rb +1 -0
  32. data/lib/grape/formatter/serializable_hash.rb +1 -0
  33. data/lib/grape/locale/en.yml +1 -0
  34. data/lib/grape/middleware/auth/dsl.rb +5 -33
  35. data/lib/grape/middleware/error.rb +153 -46
  36. data/lib/grape/middleware/formatter.rb +95 -26
  37. data/lib/grape/middleware/precomputed_content_types.rb +12 -7
  38. data/lib/grape/middleware/stack.rb +13 -4
  39. data/lib/grape/middleware/versioner/base.rb +3 -9
  40. data/lib/grape/middleware/versioner/path.rb +49 -7
  41. data/lib/grape/namespace.rb +3 -2
  42. data/lib/grape/path.rb +8 -69
  43. data/lib/grape/precompiled_json.rb +50 -0
  44. data/lib/grape/request.rb +36 -4
  45. data/lib/grape/router/base_route.rb +47 -10
  46. data/lib/grape/router/greedy_route.rb +5 -1
  47. data/lib/grape/router/pattern/path.rb +78 -0
  48. data/lib/grape/router/pattern.rb +60 -14
  49. data/lib/grape/router/route.rb +62 -10
  50. data/lib/grape/router.rb +100 -57
  51. data/lib/grape/serve_stream/file_body.rb +6 -0
  52. data/lib/grape/serve_stream/stream_response.rb +6 -0
  53. data/lib/grape/util/api_description.rb +10 -1
  54. data/lib/grape/util/cache.rb +21 -2
  55. data/lib/grape/util/deep_freeze.rb +1 -2
  56. data/lib/grape/util/freeze_on_new.rb +20 -0
  57. data/lib/grape/util/inheritable_setting.rb +751 -37
  58. data/lib/grape/util/media_type.rb +10 -3
  59. data/lib/grape/util/path_normalizer.rb +12 -9
  60. data/lib/grape/util/registry.rb +12 -2
  61. data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
  62. data/lib/grape/util/stackable_values.rb +32 -6
  63. data/lib/grape/validations/attributes_iterator.rb +21 -4
  64. data/lib/grape/validations/contract_scope.rb +8 -7
  65. data/lib/grape/validations/oneof_collector.rb +6 -10
  66. data/lib/grape/validations/params_documentation.rb +2 -2
  67. data/lib/grape/validations/params_scope.rb +66 -26
  68. data/lib/grape/validations/single_attribute_iterator.rb +5 -3
  69. data/lib/grape/validations/types/array_coercer.rb +4 -6
  70. data/lib/grape/validations/types/custom_type_coercer.rb +7 -1
  71. data/lib/grape/validations/types/dry_type_coercer.rb +3 -1
  72. data/lib/grape/validations/types/json.rb +1 -3
  73. data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
  74. data/lib/grape/validations/types/primitive_coercer.rb +12 -9
  75. data/lib/grape/validations/types/variant_collection_coercer.rb +8 -3
  76. data/lib/grape/validations/types.rb +5 -5
  77. data/lib/grape/validations/validations_spec.rb +14 -4
  78. data/lib/grape/validations/validators/base.rb +4 -7
  79. data/lib/grape/validations/validators/coerce_validator.rb +8 -2
  80. data/lib/grape/validations/validators/contract_scope_validator.rb +2 -1
  81. data/lib/grape/validations/validators/length_validator.rb +4 -2
  82. data/lib/grape/validations/validators/oneof_validator.rb +2 -0
  83. data/lib/grape/validations/validators/same_as_validator.rb +1 -0
  84. data/lib/grape/version.rb +2 -1
  85. data/lib/grape.rb +23 -2
  86. metadata +10 -9
  87. data/lib/grape/middleware/deprecated_options_hash_access.rb +0 -19
  88. data/lib/grape/util/base_inheritable.rb +0 -43
  89. data/lib/grape/util/inheritable_values.rb +0 -33
  90. data/lib/grape/util/reverse_stackable_values.rb +0 -15
@@ -23,22 +23,64 @@ module Grape
23
23
  end
24
24
 
25
25
  def before
26
- path_info = Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
26
+ routed = routed_version
27
+ return env[Grape::Env::API_VERSION] = routed if routed
28
+
29
+ path_info = env[Grape::Env::GRAPE_NORMALIZED_PATH] || Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
27
30
  return if path_info == '/'
28
31
 
29
- path_info = @prefixes.reduce(path_info) do |pi, path|
30
- pi.start_with?(path) ? pi.delete_prefix(path) : pi
31
- end
32
+ # `each` rather than `reduce`: Array does not override Enumerable's,
33
+ # so the generic accumulator path costs more than the work it drives
34
+ # on a list that holds at most a mount path and a prefix — and most
35
+ # often nothing at all.
36
+ @prefixes.each { |prefix| path_info = path_info.delete_prefix(prefix) if path_info.start_with?(prefix) }
32
37
 
33
38
  slash_position = path_info.index('/', 1) # omit the first one
34
- return unless slash_position
39
+ return version_from_first_segment(path_info, slash_position) if slash_position
35
40
 
36
- potential_version = path_info[1..(slash_position - 1)]
37
- return unless potential_version.match?(pattern)
41
+ version_from_only_segment(path_info)
42
+ end
43
+
44
+ private
45
+
46
+ # Under path versioning every route pattern carries the version as a
47
+ # named capture (Pattern::Path#build_parts inserts it), constrained to
48
+ # the declared versions -- so the router has already sliced the segment
49
+ # out and validated it, and re-deriving it from PATH_INFO would repeat
50
+ # the normalize, prefix-strip and slice below for the same answer.
51
+ #
52
+ # Nil, and the path parsed as before, when there are no routing args at
53
+ # all (the middleware used outside a Grape router) and for the greedy
54
+ # routes behind auto-OPTIONS and 405, which capture nothing. An Array
55
+ # means the route declared a +:version+ segment of its own on top of the
56
+ # versioning one: Mustermann then reports the capture as every position
57
+ # it matched rather than the single segment recorded here, so that too
58
+ # is left to the path parse.
59
+ def routed_version
60
+ version = env[Grape::Env::GRAPE_ROUTING_ARGS]&.[](:version)
61
+ version if version.is_a?(String)
62
+ end
38
63
 
64
+ def version_from_first_segment(path_info, slash_position)
65
+ potential_version = path_info[1..(slash_position - 1)]
39
66
  version_not_found! unless potential_version_match?(potential_version)
40
67
  env[Grape::Env::API_VERSION] = potential_version
41
68
  end
69
+
70
+ # The path is a single segment (e.g. `GET /v1` — the root route of a
71
+ # path-versioned API). Nothing follows to disambiguate a version from a
72
+ # plain path or from a `.format` suffix (`/v1.json`), so the version is
73
+ # only recorded on an exact match against the declared versions, and an
74
+ # unmatched segment is left for the router to resolve — never a 404.
75
+ def version_from_only_segment(path_info)
76
+ candidate = path_info[1..]
77
+ candidate = candidate[0...candidate.rindex('.')] while candidate.include?('.') && !declared_version?(candidate)
78
+ env[Grape::Env::API_VERSION] = candidate if declared_version?(candidate)
79
+ end
80
+
81
+ def declared_version?(candidate)
82
+ versions.present? && versions.include?(candidate)
83
+ end
42
84
  end
43
85
  end
44
86
  end
@@ -35,8 +35,9 @@ module Grape
35
35
  [self.class, space, requirements, options].hash
36
36
  end
37
37
 
38
- # Join the namespaces from a list of settings to create a path prefix.
39
- # @param settings [Array] list of Grape::Util::InheritableSettings.
38
+ # Join the namespaces from a list of Namespace objects to create a path
39
+ # prefix.
40
+ # @param settings [Array] list of Grape::Namespace objects.
40
41
  def self.joined_space_path(settings)
41
42
  JoinedSpaceCache[joined_space(settings)]
42
43
  end
data/lib/grape/path.rb CHANGED
@@ -1,73 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Grape
4
- # Represents a path to an endpoint.
5
- class Path
6
- DEFAULT_FORMAT_SEGMENT = '(/.:format)'
7
- NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT = '(.:format)'
8
- VERSION_SEGMENT = ':version'
9
-
10
- attr_reader :origin, :suffix
11
-
12
- def initialize(raw_path, raw_namespace, settings)
13
- @origin = PartsCache[build_parts(raw_path, raw_namespace, settings)]
14
- @suffix = build_suffix(raw_path, raw_namespace, settings)
15
- end
16
-
17
- def to_s
18
- "#{origin}#{suffix}"
19
- end
20
-
21
- private
22
-
23
- def build_suffix(raw_path, raw_namespace, settings)
24
- return "(.#{settings[:format]})" if uses_specific_format?(settings)
25
- return NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT if !uses_path_versioning?(settings) || valid_part?(raw_namespace) || valid_part?(raw_path)
26
-
27
- DEFAULT_FORMAT_SEGMENT
28
- end
29
-
30
- def build_parts(raw_path, raw_namespace, settings)
31
- parts = []
32
- add_part(parts, settings[:mount_path])
33
- add_part(parts, settings[:root_prefix])
34
- parts << VERSION_SEGMENT if uses_path_versioning?(settings)
35
- add_part(parts, raw_namespace)
36
- add_part(parts, raw_path)
37
- parts
38
- end
39
-
40
- def add_part(parts, value)
41
- parts << value if value && not_slash?(value)
42
- end
43
-
44
- def not_slash?(value)
45
- value != '/'
46
- end
47
-
48
- def uses_specific_format?(settings)
49
- return false unless settings.key?(:format) && settings.key?(:content_types)
50
-
51
- settings[:format] && Array(settings[:content_types]).size == 1
52
- end
53
-
54
- def uses_path_versioning?(settings)
55
- return false unless settings.key?(:version) && settings[:version_options]
56
-
57
- settings[:version] && settings[:version_options].using == :path
58
- end
59
-
60
- def valid_part?(part)
61
- part&.match?(/^\S/) && not_slash?(part)
62
- end
63
-
64
- class PartsCache < Grape::Util::Cache
65
- def initialize
66
- super
67
- @cache = Hash.new do |h, parts|
68
- h[parts] = Grape::Util::PathNormalizer.call(parts.join('/'))
69
- end
70
- end
71
- end
72
- end
4
+ # @deprecated +Grape::Path+ moved to {Grape::Router::Pattern::Path}, since it
5
+ # is a router-internal detail that only exists to build a
6
+ # {Grape::Router::Pattern}. Reference the new constant instead.
7
+ Path = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
8
+ 'Grape::Path',
9
+ 'Grape::Router::Pattern::Path',
10
+ Grape.deprecator
11
+ )
73
12
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ # Marks a body as JSON that has already been rendered, so the JSON formatters
5
+ # serve it verbatim instead of encoding it a second time.
6
+ #
7
+ # +Grape::Formatter::Json+ calls +to_json+ on whatever the endpoint returned, so a
8
+ # String holding pre-rendered JSON — a blob cached in Redis, a +json_agg+ column read
9
+ # straight from Postgres, output from another serializer — comes back encoded twice:
10
+ #
11
+ # '{"a":1}' => "\"{\\\"a\\\":1}\""
12
+ #
13
+ # Wrapping the body opts that one response out of encoding:
14
+ #
15
+ # get '/cached' do
16
+ # body Grape::PrecompiledJson.new(Rails.cache.read('payload'))
17
+ # end
18
+ #
19
+ # An Array is joined into a JSON array without its members being parsed, which is
20
+ # what makes a cached collection cheap — N rendered blobs are spliced together
21
+ # rather than round-tripped:
22
+ #
23
+ # Grape::PrecompiledJson.new(['{"id":1}', '{"id":2}']).to_s # => '[{"id":1},{"id":2}]'
24
+ #
25
+ # +Array#join+ calls +to_s+ on each member, so members may themselves be
26
+ # +PrecompiledJson+ instances.
27
+ #
28
+ # Wrap only the whole body. A wrapper nested inside a Hash or Array that is then
29
+ # handed to an encoder is serialized as an ordinary object — +ActiveSupport::JSON+
30
+ # renders it through +as_json+ as +{"value":"{\"a\":1}"}+ — because no encoder knows
31
+ # to unwrap it. Nothing checks that the String actually holds JSON; that is the
32
+ # caller's responsibility.
33
+ class PrecompiledJson
34
+ # @param value [String, Array<String>] pre-rendered JSON
35
+ def initialize(value)
36
+ @value = value
37
+ end
38
+
39
+ # @return [String] the JSON to serve
40
+ # @raise [Grape::Exceptions::InvalidFormatter] if the value is neither a String
41
+ # nor an Array; the formatter middleware turns this into a 500 rather than
42
+ # letting a body Rack cannot serve reach the SPEC check.
43
+ def to_s
44
+ return @value if @value.is_a?(String)
45
+ return "[#{@value.join(',')}]" if @value.is_a?(Array)
46
+
47
+ raise Grape::Exceptions::InvalidFormatter.new(@value.class, 'json')
48
+ end
49
+ end
50
+ end
data/lib/grape/request.rb CHANGED
@@ -141,7 +141,7 @@ module Grape
141
141
 
142
142
  def initialize(env, build_params_with: nil)
143
143
  super(env)
144
- @params_builder = Grape::ParamsBuilder.params_builder_for(build_params_with || Grape.config.param_builder)
144
+ @build_params_with = build_params_with
145
145
  end
146
146
 
147
147
  def params
@@ -166,10 +166,17 @@ module Grape
166
166
 
167
167
  private
168
168
 
169
+ # Resolved on first use rather than in the constructor: a request that never
170
+ # reads +params+ -- an endpoint with no validations whose block does not ask
171
+ # for them -- pays neither the registry lookup nor the global config read,
172
+ # and every request builds a Grape::Request.
173
+ def params_builder
174
+ @params_builder ||= Grape::ParamsBuilder.params_builder_for(@build_params_with || Grape.config[:param_builder])
175
+ end
176
+
169
177
  def make_params
170
- params = @params_builder.call(rack_params)
171
- routing_args = env[Grape::Env::GRAPE_ROUTING_ARGS]
172
- filtered = routing_args&.except(:version, :route_info)
178
+ params = params_builder.call(rack_params)
179
+ filtered = routing_args_as_params(env[Grape::Env::GRAPE_ROUTING_ARGS])
173
180
  return params if filtered.blank?
174
181
 
175
182
  params.deep_merge!(filtered)
@@ -177,6 +184,31 @@ module Grape
177
184
  raise Grape::Exceptions::RequestError
178
185
  end
179
186
 
187
+ # The routing args carry two things that are not request params:
188
+ # +:route_info+, which is always Grape's own, and +:version+, which is only
189
+ # Grape's own when the API declared a version — that is captured as a path
190
+ # segment and exposed through +env['api.version']+ instead.
191
+ #
192
+ # An API that declares no version can legitimately name a param +:version+
193
+ # (`route_param :version`, `get '/:version'`), and that capture belongs to
194
+ # the application. Dropping it unconditionally left `params[:version]` nil
195
+ # on a route that had matched, losing the segment silently.
196
+ def routing_args_as_params(routing_args)
197
+ return if routing_args.nil?
198
+ return routing_args.except(:version, :route_info) if grape_owns_version?(routing_args)
199
+
200
+ routing_args.except(:route_info)
201
+ end
202
+
203
+ # A route reports a +version+ only when the API declared one, which is the
204
+ # case where the captured segment is Grape's rather than the application's.
205
+ def grape_owns_version?(routing_args)
206
+ return false unless routing_args.key?(:version)
207
+
208
+ route = routing_args[:route_info]
209
+ route.respond_to?(:version) && !route.version.nil?
210
+ end
211
+
180
212
  # Uses a plain `each_header` block instead of `each_header.with_object`:
181
213
  # `with_object` can only pass the block one value plus the memo, so the
182
214
  # `k, v` pair would be boxed into a throwaway Array on every header. A
@@ -7,34 +7,71 @@ module Grape
7
7
 
8
8
  delegate_missing_to :@options
9
9
 
10
- attr_reader :options, :pattern
10
+ attr_reader :options, :pattern, :prefix, :settings, :namespace
11
11
 
12
- def_delegators :@pattern, :path, :origin
13
- def_delegators :@options, :description, :version, :requirements, :prefix, :anchor, :settings, :forward_match, *Grape::Util::ApiDescription::DSL_METHODS
12
+ # +version+, +anchor+ and +requirements+ shape the matcher, so they are
13
+ # read from the pattern rather than stored again on the route.
14
+ def_delegators :@pattern, :path, :origin, :version, :anchor, :requirements
15
+ def_delegators :@options, :description, *Grape::Util::ApiDescription::DSL_METHODS
14
16
 
15
- def initialize(pattern, options = {})
17
+ def initialize(pattern, options = {}, namespace: nil, prefix: nil, settings: nil)
16
18
  @pattern = pattern
17
19
  @options = options.is_a?(ActiveSupport::OrderedOptions) ? options : ActiveSupport::OrderedOptions.new.update(options)
20
+ @namespace = namespace
21
+ @prefix = prefix
22
+ @settings = settings
18
23
  end
19
24
 
20
- # see https://github.com/ruby-grape/grape/issues/1348
21
- def namespace
22
- @namespace ||= @options[:namespace]
25
+ # +success+ and +failure+ are the +desc+ DSL's names for +entity+ and
26
+ # +http_codes+: the block form writes the canonical key, a keyword option
27
+ # keeps the name it was written with, so both spellings are read here.
28
+ # Without this they resolve through +delegate_missing_to+, which answers
29
+ # nil for whichever of the two keys the description did not use.
30
+ def success
31
+ @options[:entity] || @options[:success]
23
32
  end
24
33
 
25
- def regexp_capture_index
26
- @regexp_capture_index ||= CaptureIndexCache[@index]
34
+ def failure
35
+ @options[:http_codes] || @options[:failure]
27
36
  end
28
37
 
38
+ # @deprecated Use {#default_response}, the name grape-swagger asks for.
39
+ # This one has to be written out rather than delegated like the rest:
40
+ # an ActiveSupport::OrderedOptions answers an unknown name with that
41
+ # key's value, but +default+ is not unknown to it — it is +Hash#default+,
42
+ # the Hash's own default value — so a delegator would report nil for
43
+ # every route.
44
+ def default
45
+ Grape.deprecator.warn('`Grape::Router::Route#default` is deprecated. Use `#default_response` instead.')
46
+ default_response
47
+ end
48
+
49
+ # Assigned eagerly in {#to_regexp} (router compilation) rather than
50
+ # memoized here: this reader is called from request-time route matching
51
+ # on instances shared across threads, so it must not write state.
52
+ attr_reader :regexp_capture_index
53
+
54
+ # The number of the group this route occupies in the union the router
55
+ # compiled it into. Only the union can say what it is, so it is written
56
+ # back by {Router#compile!} right after building one, and like
57
+ # +regexp_capture_index+ it is never assigned at request time.
58
+ attr_reader :regexp_capture_group
59
+
29
60
  def pattern_regexp
30
61
  @pattern.to_regexp
31
62
  end
32
63
 
33
64
  def to_regexp(index)
34
- @index = index
65
+ @regexp_capture_index = CaptureIndexCache[index]
35
66
  Regexp.new("(?<#{regexp_capture_index}>#{pattern_regexp})")
36
67
  end
37
68
 
69
+ # @api private
70
+ # @see #regexp_capture_group
71
+ def resolve_capture_group!(union_named_captures)
72
+ @regexp_capture_group = union_named_captures.fetch(regexp_capture_index).first
73
+ end
74
+
38
75
  class CaptureIndexCache < Grape::Util::Cache
39
76
  def initialize
40
77
  super
@@ -18,7 +18,11 @@ module Grape
18
18
  @allow_header = allow_header
19
19
  end
20
20
 
21
- def params(_input = nil)
21
+ def params
22
+ nil
23
+ end
24
+
25
+ def params_for(_input)
22
26
  nil
23
27
  end
24
28
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Router
5
+ class Pattern
6
+ # Assembles the path template a {Pattern} compiles into a matcher. It turns
7
+ # a raw path plus the path-settings snapshot taken by
8
+ # {Grape::Util::InheritableSetting#path_settings} (mount paths, root
9
+ # prefix, path-versioning and format) into an +origin+ (the route prefix)
10
+ # and a +suffix+ (the format segment). {Pattern.build} is the entry point
11
+ # that wires this into pattern construction.
12
+ class Path
13
+ DEFAULT_FORMAT_SEGMENT = '(/.:format)'
14
+ NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT = '(.:format)'
15
+ VERSION_SEGMENT = ':version'
16
+
17
+ attr_reader :origin, :suffix
18
+
19
+ def initialize(raw_path, raw_namespace, settings)
20
+ @origin = PartsCache[build_parts(raw_path, raw_namespace, settings)]
21
+ @suffix = build_suffix(raw_path, raw_namespace, settings)
22
+ end
23
+
24
+ def to_s
25
+ "#{origin}#{suffix}"
26
+ end
27
+
28
+ private
29
+
30
+ def build_suffix(raw_path, raw_namespace, settings)
31
+ return "(.#{settings.format})" if uses_specific_format?(settings)
32
+ return NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT if !uses_path_versioning?(settings) || valid_part?(raw_namespace) || valid_part?(raw_path)
33
+
34
+ DEFAULT_FORMAT_SEGMENT
35
+ end
36
+
37
+ def build_parts(raw_path, raw_namespace, settings)
38
+ parts = []
39
+ add_part(parts, settings.mount_path)
40
+ add_part(parts, settings.root_prefix)
41
+ parts << VERSION_SEGMENT if uses_path_versioning?(settings)
42
+ add_part(parts, raw_namespace)
43
+ add_part(parts, raw_path)
44
+ parts
45
+ end
46
+
47
+ def add_part(parts, value)
48
+ parts << value if value && not_slash?(value)
49
+ end
50
+
51
+ def not_slash?(value)
52
+ value != '/'
53
+ end
54
+
55
+ def uses_specific_format?(settings)
56
+ settings.format && Array(settings.content_types).size == 1
57
+ end
58
+
59
+ def uses_path_versioning?(settings)
60
+ settings.version && settings.version_options&.using == :path
61
+ end
62
+
63
+ def valid_part?(part)
64
+ part&.match?(/\A\S/) && not_slash?(part)
65
+ end
66
+
67
+ class PartsCache < Grape::Util::Cache
68
+ def initialize
69
+ super
70
+ @cache = Hash.new do |h, parts|
71
+ h[parts] = Grape::Util::PathNormalizer.call(parts.join('/'))
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -7,17 +7,43 @@ module Grape
7
7
 
8
8
  DEFAULT_CAPTURES = %w[format version].freeze
9
9
 
10
- attr_reader :origin, :path, :pattern, :to_regexp
10
+ attr_reader :origin, :path, :pattern, :to_regexp, :anchor, :version, :requirements
11
11
 
12
12
  def_delegators :pattern, :params
13
13
  def_delegators :to_regexp, :===
14
14
  alias match? ===
15
15
 
16
- def initialize(origin:, suffix:, anchor:, params:, format:, version:, requirements:)
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+;
18
+ # the Pattern itself stays value-based (see {#initialize}).
19
+ def self.build(path:, namespace:, settings:, anchor:, params:, version:, requirements:)
20
+ built_path = Path.new(path, namespace, settings)
21
+ new(origin: built_path.origin, suffix: built_path.suffix, anchor:, params:, version:, requirements:)
22
+ end
23
+
24
+ def initialize(origin:, suffix:, anchor:, params:, version:, requirements:)
17
25
  @origin = origin
26
+ @anchor = anchor
27
+ @version = version
28
+ @requirements = requirements
18
29
  @path = PatternCache[[build_path_from_pattern(@origin, anchor), suffix]]
19
- @pattern = MustermannPattern.new(@path, uri_decode: true, params:, capture: extract_capture(format, version, requirements))
30
+ @pattern = MustermannPattern.new(@path, uri_decode: true, params:, capture: extract_capture(version, requirements))
20
31
  @to_regexp = @pattern.to_regexp
32
+ @captures = @to_regexp.names.any?
33
+ end
34
+
35
+ # True when the compiled pattern has named captures to extract from a
36
+ # matched path. A fully static path has none — not even +format+, which
37
+ # the suffix spells as a literal — so asking Mustermann for its params
38
+ # would run the regexp a second time (the router's union already matched
39
+ # it) only to hand back an empty Hash.
40
+ #
41
+ # Resolved once here rather than per request: +Regexp#names+ builds an
42
+ # Array and a String per capture, and Ruby offers no predicate that skips
43
+ # that (+named_captures+ builds a Hash, and scanning the source for
44
+ # <tt>(?<</tt> would count lookbehinds, which name nothing).
45
+ def captures?
46
+ @captures
21
47
  end
22
48
 
23
49
  def captures_default
@@ -28,14 +54,38 @@ module Grape
28
54
 
29
55
  private
30
56
 
31
- def extract_capture(format, version, requirements)
32
- capture = {}
33
- capture[:format] = map_str(format) if format.present?
34
- capture[:version] = map_str(version) if version.present?
57
+ # The declared versions constrain the +:version+ capture. They are handed
58
+ # to Mustermann as one alternation Regexp rather than as the Array of
59
+ # Strings they arrive in, because an Array capture makes Mustermann build
60
+ # a *converter* for the capture -- and for an Array of plain Strings that
61
+ # converter is the identity function (Mustermann only derives one from a
62
+ # Class or a Symbol, so every entry contributes nothing and the lambda
63
+ # falls through to `|| string`).
64
+ #
65
+ # A non-empty converter table costs every request on the route: it makes
66
+ # Mustermann's +identity_params?+ fast path unreachable, so +params+
67
+ # rebuilds the capture Hash through +map_param+ and calls the do-nothing
68
+ # lambda on each value. Passing a Regexp registers no converter at all.
69
+ #
70
+ # The generated matcher differs in one respect: Mustermann expands an
71
+ # Array entry the way it expands a path literal, so each character also
72
+ # matches its own percent-encoding (+v1+ as <tt>(?:v|%76)(?:1|%31)</tt>).
73
+ # A Regexp is inserted verbatim, so a percent-encoded version segment no
74
+ # longer matches the route. It never reached the endpoint anyway --
75
+ # {Versioner::Base#potential_version_match?} compares the raw segment
76
+ # against the declared versions, so +/api/%76%31/x+ was matched here and
77
+ # then rejected as an unknown version, ending in the same cascading 404
78
+ # the router now returns directly.
79
+ #
80
+ # +Regexp.union+ takes Strings and Regexps only, and +version+ accepts
81
+ # Symbols and Integers too, so the entries are coerced first. A lone one
82
+ # would survive without it -- the single-argument path goes through
83
+ # +Regexp.escape+, which does accept a Symbol -- but a second raises
84
+ # TypeError.
85
+ def extract_capture(version, requirements)
86
+ return requirements if version.blank?
35
87
 
36
- return capture if requirements.blank?
37
-
38
- requirements.merge(capture)
88
+ requirements.merge(version: Regexp.union(Array.wrap(version).map(&:to_s)))
39
89
  end
40
90
 
41
91
  def build_path_from_pattern(pattern, anchor)
@@ -46,10 +96,6 @@ module Grape
46
96
  "#{pattern}/?*path"
47
97
  end
48
98
 
49
- def map_str(value)
50
- Array.wrap(value).map(&:to_s)
51
- end
52
-
53
99
  class PatternCache < Grape::Util::Cache
54
100
  def initialize
55
101
  super
@@ -12,15 +12,18 @@ module Grape
12
12
 
13
13
  def_delegators :@app, :call
14
14
 
15
- def initialize(endpoint, method, pattern, options)
16
- super(pattern, options)
15
+ def initialize(endpoint, method, pattern, options, forward_match:, params: {}, **route_attributes)
16
+ super(pattern, options, **route_attributes)
17
17
  @app = endpoint
18
18
  @request_method = upcase_method(method)
19
- @match_function = options[:forward_match] ? FORWARD_MATCH_METHOD : NON_FORWARD_MATCH_METHOD
19
+ @match_function = forward_match ? FORWARD_MATCH_METHOD : NON_FORWARD_MATCH_METHOD
20
+ @declared_params = params
20
21
  end
21
22
 
22
- def convert_to_head_request!
23
- @request_method = Rack::HEAD
23
+ def to_head
24
+ head = dup
25
+ head.convert_to_head_request!
26
+ head
24
27
  end
25
28
 
26
29
  def apply(app)
@@ -34,19 +37,68 @@ module Grape
34
37
  @match_function.call(input, pattern)
35
38
  end
36
39
 
37
- def params(input = nil)
38
- return params_without_input if input.blank?
40
+ # The route's declared params keyed by name — path captures plus any
41
+ # declared body/query params, as their definitions. Used for documentation
42
+ # (e.g. grape-swagger), not for extracting request values.
43
+ def params
44
+ @params ||= pattern.captures_default.merge(@declared_params)
45
+ end
46
+
47
+ # Extract param values from a matched request path. Used by the router.
48
+ #
49
+ # A pattern with no named captures has nothing to extract, so it skips the
50
+ # match entirely and answers nil — the same way {GreedyRoute#params_for}
51
+ # does, and what the router already coerces into the Hash it builds
52
+ # routing args in.
53
+ def params_for(input)
54
+ return unless pattern.captures?
39
55
 
40
56
  parsed = pattern.params(input)
41
57
  return unless parsed
42
58
 
43
- parsed.compact.symbolize_keys
59
+ params = {}
60
+ parsed.each { |name, value| params[name.to_sym] = tag_utf8!(value) unless value.nil? }
61
+ params
62
+ end
63
+
64
+ protected
65
+
66
+ def convert_to_head_request!
67
+ @request_method = Rack::HEAD
44
68
  end
45
69
 
46
70
  private
47
71
 
48
- def params_without_input
49
- @params_without_input ||= pattern.captures_default.merge(options[:params])
72
+ # Mustermann decodes path captures out of +PATH_INFO+, which Rack hands us
73
+ # tagged ASCII-8BIT, so path params came back binary while Rack tags query
74
+ # and body params UTF-8. That split makes an API's own declarations
75
+ # disagree with themselves: `values: ['café']` matched `?id=café` but not
76
+ # `/café`, since a binary string never equals the UTF-8 literal it was
77
+ # written as.
78
+ #
79
+ # Re-tag as UTF-8. Nothing obliges a client to send UTF-8: the request
80
+ # target is octets to HTTP, and Rack's SPEC has CGI keys carry non-ASCII
81
+ # as ASCII-8BIT. But UTF-8 is what browsers percent-encode with, what an
82
+ # IRI maps to, and what Rails settles on — ActionDispatch::Journey::Router
83
+ # force_encodes every path capture to UTF-8 after unescaping it.
84
+ #
85
+ # Only the encoding changes; the bytes are untouched. Octets that are not
86
+ # UTF-8 therefore stay invalid and are caught downstream rather than being
87
+ # silently scrubbed into something the client never sent.
88
+ #
89
+ # The re-tag is in place, hence the bang. Mustermann's +Pattern#params+
90
+ # builds a fresh Hash of fresh, unfrozen strings on every call and skips
91
+ # its own Match cache, so the mutation cannot escape this request — which
92
+ # is also what lets the Array branch re-tag its elements with +each+.
93
+ def tag_utf8!(value)
94
+ case value
95
+ when String
96
+ value.force_encoding(Encoding::UTF_8)
97
+ when Array
98
+ value.each { |v| tag_utf8!(v) }
99
+ else
100
+ value
101
+ end
50
102
  end
51
103
 
52
104
  def upcase_method(method)