grape 2.4.0 → 4.0.1

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +351 -0
  3. data/CONTRIBUTING.md +18 -10
  4. data/README.md +457 -228
  5. data/UPGRADING.md +1240 -1
  6. data/grape.gemspec +7 -7
  7. data/lib/grape/api/instance.rb +65 -116
  8. data/lib/grape/api.rb +53 -62
  9. data/lib/grape/content_types.rb +58 -3
  10. data/lib/grape/cookies.rb +2 -6
  11. data/lib/grape/declared_params_handler.rb +116 -0
  12. data/lib/grape/dry_types.rb +48 -4
  13. data/lib/grape/dsl/callbacks.rb +8 -58
  14. data/lib/grape/dsl/declared.rb +35 -0
  15. data/lib/grape/dsl/desc.rb +24 -67
  16. data/lib/grape/dsl/entity.rb +106 -0
  17. data/lib/grape/dsl/helpers.rb +80 -65
  18. data/lib/grape/dsl/inside_route.rb +56 -296
  19. data/lib/grape/dsl/logger.rb +4 -9
  20. data/lib/grape/dsl/middleware.rb +22 -40
  21. data/lib/grape/dsl/parameters.rb +86 -93
  22. data/lib/grape/dsl/request_response.rb +139 -139
  23. data/lib/grape/dsl/rescue_options.rb +25 -0
  24. data/lib/grape/dsl/routing.rb +328 -203
  25. data/lib/grape/dsl/settings.rb +29 -135
  26. data/lib/grape/dsl/validations.rb +38 -44
  27. data/lib/grape/dsl/version_options.rb +23 -0
  28. data/lib/grape/endpoint/options.rb +25 -0
  29. data/lib/grape/endpoint.rb +285 -245
  30. data/lib/grape/env.rb +8 -3
  31. data/lib/grape/error_formatter/base.rb +27 -21
  32. data/lib/grape/error_formatter/json.rb +6 -0
  33. data/lib/grape/error_formatter/txt.rb +10 -10
  34. data/lib/grape/error_formatter.rb +6 -2
  35. data/lib/grape/exceptions/base.rb +20 -44
  36. data/lib/grape/exceptions/error_response.rb +48 -0
  37. data/lib/grape/exceptions/incompatible_option_values.rb +1 -1
  38. data/lib/grape/exceptions/internal_server_error.rb +16 -0
  39. data/lib/grape/exceptions/invalid_accept_header.rb +1 -1
  40. data/lib/grape/exceptions/invalid_formatter.rb +1 -1
  41. data/lib/grape/exceptions/invalid_message_body.rb +1 -1
  42. data/lib/grape/exceptions/invalid_version_header.rb +1 -1
  43. data/lib/grape/exceptions/invalid_versioner_option.rb +1 -1
  44. data/lib/grape/exceptions/method_not_allowed.rb +1 -1
  45. data/lib/grape/exceptions/missing_group_type.rb +0 -2
  46. data/lib/grape/exceptions/missing_mime_type.rb +1 -1
  47. data/lib/grape/exceptions/request_error.rb +11 -0
  48. data/lib/grape/exceptions/unknown_auth_strategy.rb +1 -1
  49. data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
  50. data/lib/grape/exceptions/unknown_parameter.rb +1 -1
  51. data/lib/grape/exceptions/unknown_params_builder.rb +1 -1
  52. data/lib/grape/exceptions/unknown_validator.rb +1 -1
  53. data/lib/grape/exceptions/unsupported_group_type.rb +0 -2
  54. data/lib/grape/exceptions/validation.rb +25 -7
  55. data/lib/grape/exceptions/validation_array_errors.rb +4 -0
  56. data/lib/grape/exceptions/validation_errors.rb +21 -23
  57. data/lib/grape/formatter/json.rb +1 -0
  58. data/lib/grape/formatter/serializable_hash.rb +6 -9
  59. data/lib/grape/json.rb +38 -2
  60. data/lib/grape/locale/en.yml +3 -5
  61. data/lib/grape/middleware/auth/base.rb +4 -3
  62. data/lib/grape/middleware/auth/dsl.rb +9 -23
  63. data/lib/grape/middleware/base.rb +41 -36
  64. data/lib/grape/middleware/error.rb +271 -73
  65. data/lib/grape/middleware/formatter.rb +152 -63
  66. data/lib/grape/middleware/precomputed_content_types.rb +51 -0
  67. data/lib/grape/middleware/stack.rb +20 -11
  68. data/lib/grape/middleware/versioner/accept_version_header.rb +3 -3
  69. data/lib/grape/middleware/versioner/base.rb +40 -48
  70. data/lib/grape/middleware/versioner/header.rb +5 -23
  71. data/lib/grape/middleware/versioner/param.rb +2 -2
  72. data/lib/grape/middleware/versioner/path.rb +54 -7
  73. data/lib/grape/mountable.rb +22 -0
  74. data/lib/grape/namespace.rb +19 -11
  75. data/lib/grape/params_builder/base.rb +2 -0
  76. data/lib/grape/params_builder/hash_with_indifferent_access.rb +1 -1
  77. data/lib/grape/params_builder.rb +2 -19
  78. data/lib/grape/parser/json.rb +1 -1
  79. data/lib/grape/path.rb +8 -72
  80. data/lib/grape/precompiled_json.rb +50 -0
  81. data/lib/grape/request.rb +57 -20
  82. data/lib/grape/router/base_route.rb +53 -7
  83. data/lib/grape/router/greedy_route.rb +16 -6
  84. data/lib/grape/router/mustermann_pattern.rb +44 -0
  85. data/lib/grape/router/pattern/path.rb +78 -0
  86. data/lib/grape/router/pattern.rb +65 -37
  87. data/lib/grape/router/route.rb +66 -18
  88. data/lib/grape/router.rb +126 -115
  89. data/lib/grape/serve_stream/file_body.rb +7 -0
  90. data/lib/grape/serve_stream/sendfile_response.rb +3 -5
  91. data/lib/grape/serve_stream/stream_response.rb +7 -0
  92. data/lib/grape/testing.rb +33 -0
  93. data/lib/grape/util/api_description.rb +67 -0
  94. data/lib/grape/util/cache.rb +22 -2
  95. data/lib/grape/util/deep_freeze.rb +34 -0
  96. data/lib/grape/util/freeze_on_new.rb +20 -0
  97. data/lib/grape/util/inheritable_setting.rb +777 -39
  98. data/lib/grape/util/lazy/base.rb +16 -0
  99. data/lib/grape/util/lazy/block.rb +2 -9
  100. data/lib/grape/util/lazy/value.rb +2 -9
  101. data/lib/grape/util/lazy/value_enumerable.rb +13 -16
  102. data/lib/grape/util/media_type.rb +13 -9
  103. data/lib/grape/util/path_normalizer.rb +37 -0
  104. data/lib/grape/util/registry.rb +13 -3
  105. data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
  106. data/lib/grape/util/stackable_values.rb +39 -10
  107. data/lib/grape/util/translation.rb +42 -0
  108. data/lib/grape/validations/attributes_iterator.rb +61 -29
  109. data/lib/grape/validations/coerce_options.rb +21 -0
  110. data/lib/grape/validations/contract_scope.rb +8 -13
  111. data/lib/grape/validations/multiple_attributes_iterator.rb +1 -1
  112. data/lib/grape/validations/oneof_collector.rb +35 -0
  113. data/lib/grape/validations/param_scope_tracker.rb +62 -0
  114. data/lib/grape/validations/params_documentation.rb +52 -0
  115. data/lib/grape/validations/params_scope.rb +217 -304
  116. data/lib/grape/validations/shared_options.rb +19 -0
  117. data/lib/grape/validations/single_attribute_iterator.rb +6 -4
  118. data/lib/grape/validations/types/array_coercer.rb +7 -10
  119. data/lib/grape/validations/types/custom_type_coercer.rb +47 -85
  120. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  121. data/lib/grape/validations/types/dry_type_coercer.rb +10 -15
  122. data/lib/grape/validations/types/json.rb +1 -3
  123. data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
  124. data/lib/grape/validations/types/primitive_coercer.rb +14 -33
  125. data/lib/grape/validations/types/set_coercer.rb +1 -1
  126. data/lib/grape/validations/types/variant_collection_coercer.rb +16 -3
  127. data/lib/grape/validations/types.rb +29 -51
  128. data/lib/grape/validations/validations_spec.rb +164 -0
  129. data/lib/grape/validations/validators/all_or_none_of_validator.rb +6 -3
  130. data/lib/grape/validations/validators/allow_blank_validator.rb +10 -5
  131. data/lib/grape/validations/validators/at_least_one_of_validator.rb +5 -2
  132. data/lib/grape/validations/validators/base.rb +118 -34
  133. data/lib/grape/validations/validators/coerce_validator.rb +25 -37
  134. data/lib/grape/validations/validators/contract_scope_validator.rb +10 -5
  135. data/lib/grape/validations/validators/default_validator.rb +13 -20
  136. data/lib/grape/validations/validators/exactly_one_of_validator.rb +10 -3
  137. data/lib/grape/validations/validators/except_values_validator.rb +14 -4
  138. data/lib/grape/validations/validators/length_validator.rb +23 -22
  139. data/lib/grape/validations/validators/multiple_params_base.rb +12 -9
  140. data/lib/grape/validations/validators/{mutual_exclusion_validator.rb → mutually_exclusive_validator.rb} +4 -2
  141. data/lib/grape/validations/validators/oneof_validator.rb +51 -0
  142. data/lib/grape/validations/validators/presence_validator.rb +4 -2
  143. data/lib/grape/validations/validators/regexp_validator.rb +11 -3
  144. data/lib/grape/validations/validators/same_as_validator.rb +7 -15
  145. data/lib/grape/validations/validators/values_validator.rb +34 -26
  146. data/lib/grape/version.rb +1 -2
  147. data/lib/grape/xml.rb +8 -1
  148. data/lib/grape.rb +58 -31
  149. metadata +62 -39
  150. data/lib/grape/api/helpers.rb +0 -9
  151. data/lib/grape/dsl/api.rb +0 -17
  152. data/lib/grape/dsl/configuration.rb +0 -15
  153. data/lib/grape/exceptions/conflicting_types.rb +0 -11
  154. data/lib/grape/exceptions/empty_message_body.rb +0 -11
  155. data/lib/grape/exceptions/invalid_parameters.rb +0 -11
  156. data/lib/grape/exceptions/missing_option.rb +0 -11
  157. data/lib/grape/exceptions/too_deep_parameters.rb +0 -11
  158. data/lib/grape/exceptions/too_many_multipart_files.rb +0 -11
  159. data/lib/grape/exceptions/unknown_options.rb +0 -11
  160. data/lib/grape/extensions/active_support/hash_with_indifferent_access.rb +0 -24
  161. data/lib/grape/extensions/hash.rb +0 -27
  162. data/lib/grape/extensions/hashie/mash.rb +0 -24
  163. data/lib/grape/middleware/globals.rb +0 -14
  164. data/lib/grape/types/invalid_value.rb +0 -8
  165. data/lib/grape/util/base_inheritable.rb +0 -43
  166. data/lib/grape/util/inheritable_values.rb +0 -29
  167. data/lib/grape/util/reverse_stackable_values.rb +0 -15
  168. data/lib/grape/util/strict_hash_configuration.rb +0 -108
  169. data/lib/grape/validations/attributes_doc.rb +0 -60
  170. data/lib/grape/validations/validator_factory.rb +0 -15
@@ -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, options)
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
18
- @path = build_path(origin, options[:anchor], suffix)
19
- @pattern = build_pattern(@path, options[:params], options[:format], options[:version], options[:requirements])
26
+ @anchor = anchor
27
+ @version = version
28
+ @requirements = requirements
29
+ @path = PatternCache[[build_path_from_pattern(@origin, anchor), suffix]]
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,44 +54,46 @@ module Grape
28
54
 
29
55
  private
30
56
 
31
- def build_pattern(path, params, format, version, requirements)
32
- Mustermann::Grape.new(
33
- path,
34
- uri_decode: true,
35
- params: params,
36
- capture: extract_capture(format, version, requirements)
37
- )
38
- end
39
-
40
- def build_path(pattern, anchor, suffix)
41
- PatternCache[[build_path_from_pattern(pattern, anchor), suffix]]
42
- end
43
-
44
- def extract_capture(format, version, requirements)
45
- capture = {}.tap do |h|
46
- h[:format] = map_str(format) if format.present?
47
- h[:version] = map_str(version) if version.present?
48
- end
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?
49
87
 
50
- return capture if requirements.blank?
51
-
52
- requirements.merge(capture)
88
+ requirements.merge(version: Regexp.union(Array.wrap(version).map(&:to_s)))
53
89
  end
54
90
 
55
91
  def build_path_from_pattern(pattern, anchor)
56
- if pattern.end_with?('*path')
57
- pattern.dup.insert(pattern.rindex('/') + 1, '?')
58
- elsif anchor
59
- pattern
60
- elsif pattern.end_with?('/')
61
- "#{pattern}?*path"
62
- else
63
- "#{pattern}/?*path"
64
- end
65
- end
92
+ return pattern.dup.insert(pattern.rindex('/') + 1, '?') if pattern.end_with?('*path')
93
+ return pattern if anchor
94
+ return "#{pattern}?*path" if pattern.end_with?('/')
66
95
 
67
- def map_str(value)
68
- Array.wrap(value).map(&:to_s)
96
+ "#{pattern}/?*path"
69
97
  end
70
98
 
71
99
  class PatternCache < Grape::Util::Cache
@@ -8,23 +8,22 @@ module Grape
8
8
  FORWARD_MATCH_METHOD = ->(input, pattern) { input.start_with?(pattern.origin) }
9
9
  NON_FORWARD_MATCH_METHOD = ->(input, pattern) { pattern.match?(input) }
10
10
 
11
- attr_reader :app, :request_method
11
+ attr_reader :app, :request_method, :index
12
12
 
13
- def_delegators :pattern, :path, :origin
13
+ def_delegators :@app, :call
14
14
 
15
- def initialize(method, origin, path, options)
15
+ def initialize(endpoint, method, pattern, options, forward_match:, params: {}, **route_attributes)
16
+ super(pattern, options, **route_attributes)
17
+ @app = endpoint
16
18
  @request_method = upcase_method(method)
17
- @pattern = Grape::Router::Pattern.new(origin, path, options)
18
- @match_function = options[:forward_match] ? FORWARD_MATCH_METHOD : NON_FORWARD_MATCH_METHOD
19
- super(options)
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
24
- end
25
-
26
- def exec(env)
27
- @app.call(env)
23
+ def to_head
24
+ head = dup
25
+ head.convert_to_head_request!
26
+ head
28
27
  end
29
28
 
30
29
  def apply(app)
@@ -38,19 +37,68 @@ module Grape
38
37
  @match_function.call(input, pattern)
39
38
  end
40
39
 
41
- def params(input = nil)
42
- 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?
43
55
 
44
56
  parsed = pattern.params(input)
45
- return {} unless parsed
57
+ return unless parsed
46
58
 
47
- 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
48
68
  end
49
69
 
50
70
  private
51
71
 
52
- def params_without_input
53
- @params_without_input ||= pattern.captures_default.merge(attributes.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
54
102
  end
55
103
 
56
104
  def upcase_method(method)
data/lib/grape/router.rb CHANGED
@@ -2,71 +2,53 @@
2
2
 
3
3
  module Grape
4
4
  class Router
5
- attr_reader :map, :compiled
6
-
7
- # Taken from Rails
8
- # normalize_path("/foo") # => "/foo"
9
- # normalize_path("/foo/") # => "/foo"
10
- # normalize_path("foo") # => "/foo"
11
- # normalize_path("") # => "/"
12
- # normalize_path("/%ab") # => "/%AB"
13
- # https://github.com/rails/rails/blob/00cc4ff0259c0185fe08baadaa40e63ea2534f6e/actionpack/lib/action_dispatch/journey/router/utils.rb#L19
14
- def self.normalize_path(path)
15
- return +'/' unless path
16
-
17
- # Fast path for the overwhelming majority of paths that don't need to be normalized
18
- return path.dup if path == '/' || (path.start_with?('/') && !(path.end_with?('/') || path.match?(%r{%|//})))
19
-
20
- # Slow path
21
- encoding = path.encoding
22
- path = +"/#{path}"
23
- path.squeeze!('/')
24
-
25
- unless path == '/'
26
- path.delete_suffix!('/')
27
- path.gsub!(/(%[a-f0-9]{2})/) { ::Regexp.last_match(1).upcase }
28
- end
29
-
30
- path.force_encoding(encoding)
31
- end
32
-
33
5
  def initialize
34
6
  @neutral_map = []
35
7
  @neutral_regexes = []
36
- @map = Hash.new { |hash, key| hash[key] = [] }
37
- @optimized_map = Hash.new { |hash, key| hash[key] = // }
8
+ # Plain hashes with no auto-vivifying default: a lookup for an HTTP method
9
+ # that has no routes must not insert a key. `compile!` freezes both maps,
10
+ # so request-time reads never mutate shared state (see #match? / #rotation).
11
+ @map = {}
12
+ @optimized_map = {}
38
13
  end
39
14
 
40
15
  def compile!
41
- return if compiled
16
+ return if @compiled
42
17
 
43
- @union = Regexp.union(@neutral_regexes)
18
+ @union = resolve_capture_groups(Regexp.union(@neutral_regexes), @neutral_map)
44
19
  @neutral_regexes = nil
45
- (Grape::HTTP_SUPPORTED_METHODS + ['*']).each do |method|
46
- next unless map.key?(method)
47
-
48
- routes = map[method]
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|
49
28
  optimized_map = routes.map.with_index { |route, index| route.to_regexp(index) }
50
- @optimized_map[method] = Regexp.union(optimized_map)
29
+ @optimized_map[method] = resolve_capture_groups(Regexp.union(optimized_map), routes)
51
30
  end
31
+ @map.freeze
32
+ @optimized_map.freeze
52
33
  @compiled = true
53
34
  end
54
35
 
55
36
  def append(route)
56
- map[route.request_method] << route
37
+ (@map[route.request_method] ||= []) << route
57
38
  end
58
39
 
59
- def associate_routes(pattern, options)
60
- Grape::Router::GreedyRoute.new(pattern, options).then do |greedy_route|
61
- @neutral_regexes << greedy_route.to_regexp(@neutral_map.length)
62
- @neutral_map << greedy_route
63
- end
40
+ def associate_routes(greedy_route)
41
+ @neutral_regexes << greedy_route.to_regexp(@neutral_map.length)
42
+ @neutral_map << greedy_route
64
43
  end
65
44
 
66
45
  def call(env)
67
46
  with_optimization do
68
- response, route = identity(env)
69
- response || rotation(env, route)
47
+ # Published on the env so the middleware the matched route runs -- the
48
+ # path versioner above all -- reads the path this routed on instead of
49
+ # normalizing PATH_INFO a second time.
50
+ input = env[Grape::Env::GRAPE_NORMALIZED_PATH] = Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
51
+ transaction(input, env[Rack::REQUEST_METHOD], env)
70
52
  end
71
53
  end
72
54
 
@@ -77,114 +59,143 @@ module Grape
77
59
  any.endpoint
78
60
  end
79
61
 
62
+ DEFAULT_RESPONSE_HEADERS = Grape::Util::Header.new.merge('X-Cascade' => 'pass').freeze
63
+ DEFAULT_RESPONSE_BODY = ['404 Not Found'].freeze
64
+
80
65
  private
81
66
 
82
- def identity(env)
83
- route = nil
84
- response = transaction(env) do |input, method|
85
- route = match?(input, method)
86
- process_route(route, env) if route
67
+ # Resolve +input+ against the compiled routes, in priority order:
68
+ #
69
+ # 1. the routes registered for +method+ — the compiled-union match first,
70
+ # then, when that route cascades, its siblings (see #rotation);
71
+ # 2. the ANY (+'*'+) routes;
72
+ # 3. the greedy neighbour, which answers auto-OPTIONS and 405.
73
+ #
74
+ # Returns nil when nothing answered, leaving the caller to 404. A response
75
+ # that cascades is never final: it is returned only once every later
76
+ # candidate has declined too, so the caller (or a mounting app upstream)
77
+ # can keep looking.
78
+ def transaction(input, method, env)
79
+ exact_route = match?(input, method)
80
+ response = process_route(exact_route, input, env) if exact_route
81
+ return response if halt?(response)
82
+
83
+ # A cascading route has only declined this request. Its siblings — the
84
+ # routes sharing this path but differing in, say, version — must be
85
+ # tried before falling back to the ANY routes and the greedy neighbour.
86
+ # Skipped when nothing matched: the compiled union is the disjunction of
87
+ # the same patterns #rotation walks, so a miss there is a miss here.
88
+ cascaded = !response.nil?
89
+ if cascaded
90
+ response = rotation(input, method, env, exact_route)
91
+ return response if response && !cascade?(response)
87
92
  end
88
- [response, route]
89
- end
90
93
 
91
- def rotation(env, exact_route = nil)
92
- response = nil
93
- input, method = *extract_input_and_method(env)
94
- map[method].each do |route|
95
- next if exact_route == route
96
- next unless route.match?(input)
94
+ last_neighbor_route = greedy_match?(input)
97
95
 
98
- response = process_route(route, env)
99
- break unless cascade?(response)
100
- end
101
- response
102
- end
96
+ # If last_neighbor_route exists and request method is OPTIONS,
97
+ # return response by using #include_allow_header.
98
+ return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && method == Rack::OPTIONS && last_neighbor_route
103
99
 
104
- def transaction(env)
105
- input, method = *extract_input_and_method(env)
100
+ star_route = match?(input, '*')
106
101
 
107
- # using a Proc is important since `return` will exit the enclosing function
108
- cascade_or_return_response = proc do |response|
109
- if response
110
- cascade?(response).tap do |cascade|
111
- return response unless cascade
102
+ if star_route
103
+ close_body(response) if response # superseded by the ANY route
104
+ response = process_route(star_route, input, env)
105
+ return response if halt?(response)
112
106
 
113
- # we need to close the body if possible before dismissing
114
- response[2].try(:close)
115
- end
116
- end
107
+ cascaded ||= !response.nil?
117
108
  end
118
109
 
119
- last_response_cascade = cascade_or_return_response.call(yield(input, method))
120
- last_neighbor_route = greedy_match?(input)
110
+ return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && last_neighbor_route
121
111
 
122
- # If last_neighbor_route exists and request method is OPTIONS,
123
- # return response by using #call_with_allow_headers.
124
- return call_with_allow_headers(env, last_neighbor_route) if last_neighbor_route && method == Rack::OPTIONS && !last_response_cascade
125
-
126
- route = match?(input, '*')
112
+ response
113
+ end
127
114
 
128
- return last_neighbor_route.options[:endpoint].call(env) if last_neighbor_route && last_response_cascade && route
115
+ # The routes registered for +method+ other than +exact_route+, tried in
116
+ # registration order until one answers without cascading. Returns the last
117
+ # response processed — a cascading one when every sibling declined, so the
118
+ # caller can hand it back — or nil when no sibling matched.
119
+ def rotation(input, method, env, exact_route)
120
+ response = nil
121
+ @map[method]&.each do |route|
122
+ next if exact_route == route
123
+ next unless route.match?(input)
129
124
 
130
- last_response_cascade = cascade_or_return_response.call(process_route(route, env)) if route
125
+ close_body(response) if response # the previous sibling cascaded
126
+ response = process_route(route, input, env)
127
+ break unless cascade?(response)
128
+ end
129
+ response
130
+ end
131
131
 
132
- return call_with_allow_headers(env, last_neighbor_route) if !last_response_cascade && last_neighbor_route
132
+ # Returns true if `response` should be returned as-is from the enclosing
133
+ # transaction. Closes the body as a side effect when the response is
134
+ # cascading so callers can safely try the next match.
135
+ def halt?(response)
136
+ return false unless response
133
137
 
134
- nil
138
+ cascade = cascade?(response)
139
+ close_body(response) if cascade
140
+ !cascade
135
141
  end
136
142
 
137
- def process_route(route, env)
138
- prepare_env_from_route(env, route)
139
- route.exec(env)
143
+ # Releases a response the router has decided not to return. Rack requires
144
+ # every body it hands out to be closed, and a cascading candidate is
145
+ # discarded as soon as a later one answers.
146
+ def close_body(response)
147
+ body = response[2]
148
+ body.close if body.respond_to?(:close)
140
149
  end
141
150
 
142
- def make_routing_args(default_args, route, input)
143
- args = default_args || { route_info: route }
144
- args.merge(route.params(input))
151
+ # Routing args are rebuilt for every attempt: when a route cascades
152
+ # (X-Cascade pass), the next candidate must not observe the previous
153
+ # attempt's +route_info+ or path captures.
154
+ def process_route(route, input, env, include_allow_header: false)
155
+ # The path captures are the hash: +route_info+ is written into them
156
+ # rather than merged in from a second one.
157
+ routing_args = route.params_for(input) || {}
158
+ routing_args[:route_info] = route
159
+ env[Grape::Env::GRAPE_ROUTING_ARGS] = routing_args
160
+ env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.allow_header if include_allow_header
161
+ route.call(env)
145
162
  end
146
163
 
147
- def extract_input_and_method(env)
148
- input = string_for(env[Rack::PATH_INFO])
149
- method = env[Rack::REQUEST_METHOD]
150
- [input, method]
164
+ # Tells each route the number of the group it ended up as in +union+. The
165
+ # numbering is a property of the union rather than of the route's own
166
+ # pattern -- every route ahead of it contributes however many groups its
167
+ # pattern declares -- so it can only be resolved once the union is built.
168
+ # Returns the union, so a caller can assign it in one expression.
169
+ def resolve_capture_groups(union, routes)
170
+ named_captures = union.named_captures
171
+ routes.each { |route| route.resolve_capture_group!(named_captures) }
172
+ union
151
173
  end
152
174
 
153
175
  def with_optimization
154
- compile! unless compiled
176
+ compile!
155
177
  yield || default_response
156
178
  end
157
179
 
158
180
  def default_response
159
- headers = Grape::Util::Header.new.merge('X-Cascade' => 'pass')
160
- [404, headers, ['404 Not Found']]
181
+ [404, DEFAULT_RESPONSE_HEADERS.dup, DEFAULT_RESPONSE_BODY.dup]
161
182
  end
162
183
 
184
+ # Which alternative of the union matched is answered by scanning one group
185
+ # per registered route, so on an API with many of them that scan is what a
186
+ # request costs. The groups are indexed by number rather than by name: a
187
+ # name sends MatchData through the pattern's name table on every lookup,
188
+ # a number indexes the match region directly.
163
189
  def match?(input, method)
164
- @optimized_map[method].match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_index] } }
190
+ @optimized_map[method]&.match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_group] } }
165
191
  end
166
192
 
167
193
  def greedy_match?(input)
168
- @union.match(input) { |m| @neutral_map.detect { |route| m[route.regexp_capture_index] } }
169
- end
170
-
171
- def call_with_allow_headers(env, route)
172
- prepare_env_from_route(env, route)
173
- env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.options[:allow_header]
174
- route.options[:endpoint].call(env)
175
- end
176
-
177
- def prepare_env_from_route(env, route)
178
- input, = *extract_input_and_method(env)
179
- env[Grape::Env::GRAPE_ROUTING_ARGS] = make_routing_args(env[Grape::Env::GRAPE_ROUTING_ARGS], route, input)
194
+ @union.match(input) { |m| @neutral_map.detect { |route| m[route.regexp_capture_group] } }
180
195
  end
181
196
 
182
197
  def cascade?(response)
183
198
  response && response[1]['X-Cascade'] == 'pass'
184
199
  end
185
-
186
- def string_for(input)
187
- self.class.normalize_path(input)
188
- end
189
200
  end
190
201
  end
@@ -31,6 +31,13 @@ module Grape
31
31
  def ==(other)
32
32
  path == other.path
33
33
  end
34
+ alias eql? ==
35
+
36
+ # Mirrors #==, which keys on the path alone and does not check the
37
+ # class, so the class must stay out of the hash too.
38
+ def hash
39
+ path.hash
40
+ end
34
41
  end
35
42
  end
36
43
  end
@@ -6,11 +6,9 @@ module Grape
6
6
  # for using Rack::SendFile middleware
7
7
  class SendfileResponse < Rack::Response
8
8
  def respond_to?(method_name, include_all = false)
9
- if method_name == :to_path
10
- @body.respond_to?(:to_path, include_all)
11
- else
12
- super
13
- end
9
+ return @body.respond_to?(:to_path, include_all) if method_name == :to_path
10
+
11
+ super
14
12
  end
15
13
 
16
14
  def to_path