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
@@ -13,8 +13,6 @@ module Grape
13
13
  :grape_exceptions_rescue_handler, :internal_grape_exceptions_rescue_handler,
14
14
  :rescue_all, :rescue_grape_exceptions, :rescue_handlers, :rescue_options
15
15
  ) do
16
- include Grape::Middleware::DeprecatedOptionsHashAccess
17
-
18
16
  def initialize(
19
17
  all_rescue_handler: nil, base_only_rescue_handlers: nil, content_types: nil,
20
18
  default_error_formatter: nil, default_message: '', default_status: 500,
@@ -23,19 +21,23 @@ module Grape
23
21
  rescue_all: false, rescue_grape_exceptions: false, rescue_handlers: nil,
24
22
  rescue_options: nil
25
23
  )
26
- # `rescue_options:` arrives nil from `Endpoint#error_middleware_options`
27
- # when no `rescue_from` has been called — fall back to the documented
28
- # defaults rather than letting nil propagate to `def_delegator
29
- # :rescue_options, :backtrace`.
30
- rescue_options ||= Grape::DSL::RescueOptions.new
31
- super
24
+ # `rescue_options:` and `default_error_formatter:` arrive nil from
25
+ # `Endpoint#error_middleware_options` when the API called no
26
+ # `rescue_from` and no `format` — and an explicit nil bypasses the
27
+ # keyword defaults above, so restore them here rather than letting nil
28
+ # propagate to `def_delegator :rescue_options, :backtrace` or to the
29
+ # formatter lookup in `#format_message`.
30
+ super(
31
+ rescue_options: rescue_options || Grape::DSL::RescueOptions.new,
32
+ default_error_formatter: default_error_formatter || Grape::ErrorFormatter::Txt,
33
+ all_rescue_handler:, base_only_rescue_handlers:, content_types:,
34
+ default_message:, default_status:, error_formatters:, format:,
35
+ grape_exceptions_rescue_handler:, internal_grape_exceptions_rescue_handler:,
36
+ rescue_all:, rescue_grape_exceptions:, rescue_handlers:
37
+ )
32
38
  end
33
39
  end
34
40
 
35
- # @deprecated Kept as a frozen Hash representation of the {Options}
36
- # defaults for back-compat. Will be removed in a future release.
37
- DEFAULT_OPTIONS = Options.new.to_h.freeze
38
-
39
41
  def_delegators :config,
40
42
  :all_rescue_handler, :base_only_rescue_handlers, :default_error_formatter,
41
43
  :default_message, :default_status, :error_formatters, :format,
@@ -48,6 +50,13 @@ module Grape
48
50
  def_delegator :rescue_options, :backtrace, :include_backtrace
49
51
  def_delegator :rescue_options, :original_exception, :include_original_exception
50
52
 
53
+ # Emitted by {#render_failsafe_response} once even the framework's own message
54
+ # could not be rendered. Deliberately built without a formatter, an i18n
55
+ # lookup or anything else that could be the thing that is broken.
56
+ FAILSAFE_STATUS = 500
57
+ FAILSAFE_MESSAGE = '500 Internal Server Error'
58
+ FAILSAFE_CONTENT_TYPE = 'text/plain'
59
+
51
60
  def call!(env)
52
61
  @env = env
53
62
  error_response(catch(:error) { return @app.call(@env) })
@@ -58,8 +67,8 @@ module Grape
58
67
  private
59
68
 
60
69
  def rack_response(status, headers, message)
61
- message = Rack::Utils.escape_html(message) if html_content_type?(headers[Rack::CONTENT_TYPE])
62
- Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers))
70
+ body = html_content_type?(headers[Rack::CONTENT_TYPE]) ? Rack::Utils.escape_html(message) : message
71
+ Rack::Response.new(Array.wrap(body), Rack::Utils.status_code(status), Grape::Util::Header.new.merge!(headers))
63
72
  end
64
73
 
65
74
  # Escaping must key off the media type only, case-insensitively. Comparing
@@ -73,21 +82,25 @@ module Grape
73
82
  Grape::ContentTypes.media_type(content_type).to_s.casecmp?('text/html')
74
83
  end
75
84
 
85
+ # The registry answers nil for a format nothing is registered for, and
86
+ # the API's +default_error_formatter+ — +ErrorFormatter::Txt+ unless the
87
+ # API set another — takes it from there, so this always has something
88
+ # callable. The +throw :error, 406+ that used to stand in for a missing
89
+ # formatter could not work anyway: nothing catches +:error+ around this
90
+ # call (+#call!+ has left its +catch+ by the time +error_response+ runs),
91
+ # so it raised +UncaughtThrowError+ and the request answered with the
92
+ # failsafe 500 rather than the 406 it named.
76
93
  def format_message(error)
77
94
  current_format = env[Grape::Env::API_FORMAT] || format
78
- formatter = Grape::ErrorFormatter.formatter_for(current_format, error_formatters, default_error_formatter)
79
- return formatter.call(error:, env:, include_backtrace:, include_original_exception:) if formatter
80
-
81
- throw :error, Grape::Exceptions::ErrorResponse.new(
82
- status: 406,
83
- message: "The requested format '#{current_format}' is not supported.",
84
- backtrace: error.backtrace,
85
- original_exception: error.original_exception
86
- )
95
+ formatter = Grape::ErrorFormatter.formatter_for(current_format, error_formatters) || default_error_formatter
96
+ formatter.call(error:, env:, include_backtrace:, include_original_exception:)
87
97
  end
88
98
 
89
99
  def find_handler(klass)
90
- registered_rescue_handler(klass) ||
100
+ registered_entry = registered_rescue_handler_entry(klass)
101
+
102
+ grape_exceptions_precedence_handler(klass, registered_entry) ||
103
+ registered_entry&.last ||
91
104
  rescue_handler_for_grape_exception(klass) ||
92
105
  rescue_handler_for_any_class(klass) ||
93
106
  raise
@@ -101,10 +114,82 @@ module Grape
101
114
  status: raw.status || default_status,
102
115
  message: raw.message || default_message,
103
116
  headers:,
104
- backtrace: raw.backtrace || raw.original_exception&.backtrace || []
117
+ backtrace: resolved_backtrace(raw)
105
118
  )
106
119
  env[Grape::Env::API_ENDPOINT].status(payload.status) # error! may not have been called
120
+ render_response(payload)
121
+ end
122
+
123
+ # The backtrace an error formatter is handed. Assembled only when the API
124
+ # asked for one with `rescue_from ..., backtrace: true`: reading it off
125
+ # the exception is not free — +Exception#backtrace+ builds the whole Array
126
+ # of location strings — and every built-in formatter drops it otherwise.
127
+ # A formatter that wants one regardless still has +original_exception+.
128
+ def resolved_backtrace(raw)
129
+ return [] unless include_backtrace
130
+
131
+ raw.backtrace || raw.original_exception&.backtrace || []
132
+ end
133
+
134
+ # Rendering runs inside #call!'s own rescue clause, so it is not covered by
135
+ # that rescue: an error formatter that raises on the payload it was handed
136
+ # takes the exception out through every middleware above Grape. By this
137
+ # point Grape has committed to answering with an error, so it answers with
138
+ # one that does not depend on the payload rather than dropping the request.
139
+ #
140
+ # +Grape.config.raise_rendering_errors+ opts back out, for an application
141
+ # that would rather have the exception propagate as it did before.
142
+ def render_response(payload)
107
143
  rack_response(payload.status, payload.headers, format_message(payload))
144
+ rescue StandardError => e
145
+ raise if Grape.config.raise_rendering_errors
146
+
147
+ record_rendering_failure(e)
148
+ render_failsafe_response
149
+ end
150
+
151
+ # The exception is published on the rack env (see {#expose_exception}) and
152
+ # written to +rack.errors+ so it reaches the log with no tracker installed.
153
+ # Rails writes to $stderr from its failsafe branch for the same reason:
154
+ # deferring the logging to the application is not an option when the
155
+ # application's own error rendering is what broke.
156
+ def record_rendering_failure(error)
157
+ expose_exception(error)
158
+ env[Rack::RACK_ERRORS]&.write("Grape could not render the error response: #{error.class}: #{error.message}\n")
159
+ end
160
+
161
+ # First retry the API's own format with the framework's InternalServerError,
162
+ # whose message is a static string and so cannot be what defeated the first
163
+ # attempt. Should even that fail — a wholesale broken formatter, rather than
164
+ # one payload it choked on — drop the formatter entirely. Both attempts call
165
+ # {#format_message} directly rather than re-entering {#error_response}, so
166
+ # this path cannot recurse.
167
+ def render_failsafe_response
168
+ headers = { Rack::CONTENT_TYPE => content_type }
169
+ rack_response(FAILSAFE_STATUS, headers, format_message(failsafe_payload(headers)))
170
+ rescue StandardError
171
+ rack_response(FAILSAFE_STATUS, { Rack::CONTENT_TYPE => FAILSAFE_CONTENT_TYPE }, FAILSAFE_MESSAGE)
172
+ end
173
+
174
+ # Publish an exception Grape swallowed, on both keys. +grape.exception+ is
175
+ # Grape's own and has been set on these paths all along; +rack.exception+ is
176
+ # what the ecosystem actually reads to find an exception that never
177
+ # propagated — sentry-ruby collects +env['rack.exception'] ||
178
+ # env['sinatra.error']+ — so a tracker mounted above Grape keeps reporting
179
+ # these with no application change.
180
+ def expose_exception(error)
181
+ env[Grape::Env::GRAPE_EXCEPTION] = error
182
+ env[Grape::Env::RACK_EXCEPTION] = error
183
+ end
184
+
185
+ def failsafe_payload(headers)
186
+ Grape::Exceptions::ErrorResponse.new(
187
+ status: FAILSAFE_STATUS,
188
+ message: Grape::Exceptions::InternalServerError.new.message,
189
+ headers:,
190
+ backtrace: [],
191
+ original_exception: nil
192
+ )
108
193
  end
109
194
 
110
195
  def default_rescue_handler(exception)
@@ -118,16 +203,51 @@ module Grape
118
203
  end
119
204
 
120
205
  def registered_rescue_handler(klass)
121
- rescue_handler_from(base_only_rescue_handlers) { |err| klass == err } ||
122
- rescue_handler_from(rescue_handlers) { |err| klass <= err }
206
+ registered_rescue_handler_entry(klass)&.last
123
207
  end
124
208
 
125
- def rescue_handler_from(handlers)
209
+ # The matched entry rather than just its handler, so callers can tell
210
+ # *which* class matched — see {#grape_exceptions_precedence_handler}.
211
+ # @return [Array(Class, #call), nil]
212
+ def registered_rescue_handler_entry(klass)
213
+ rescue_handler_entry_from(base_only_rescue_handlers) { |err| klass == err } ||
214
+ rescue_handler_entry_from(rescue_handlers) { |err| klass <= err }
215
+ end
216
+
217
+ def rescue_handler_entry_from(handlers)
126
218
  error, handler = handlers&.find { |err, _handler| yield(err) }
127
219
 
128
220
  return unless error
129
221
 
130
- handler || method(:default_rescue_handler)
222
+ [error, handler || method(:default_rescue_handler)]
223
+ end
224
+
225
+ # +rescue_from :grape_exceptions+ is an opt-in to keep Grape's own errors
226
+ # rendering with their own status — a validation failure stays a 400
227
+ # instead of becoming whatever the app's catch-all returns.
228
+ #
229
+ # It only ever worked against +rescue_from :all+, which lives in
230
+ # +all_rescue_handler+ and is consulted last. Spelled as a class instead,
231
+ # +rescue_from StandardError+ is a *registered* handler, matched first,
232
+ # and Grape's exceptions are StandardErrors — so the opt-in silently did
233
+ # nothing and validation errors came back as 500s either way.
234
+ #
235
+ # Let it win over a handler that only matched through a non-Grape
236
+ # ancestor. One registered for a Grape exception class is more specific
237
+ # than the opt-in and still wins, so an explicit
238
+ # +rescue_from Grape::Exceptions::ValidationErrors+ keeps its handler.
239
+ #
240
+ # InvalidVersionHeader is left alone: it must keep reaching Rack so the
241
+ # next versioned route is tried.
242
+ def grape_exceptions_precedence_handler(klass, registered_entry)
243
+ return unless rescue_grape_exceptions
244
+ return unless klass <= Grape::Exceptions::Base
245
+ return if klass == Grape::Exceptions::InvalidVersionHeader
246
+
247
+ matched, = registered_entry
248
+ return if matched.nil? || matched <= Grape::Exceptions::Base
249
+
250
+ grape_exceptions_rescue_handler || method(:error_response)
131
251
  end
132
252
 
133
253
  def rescue_handler_for_grape_exception(klass)
@@ -146,9 +266,9 @@ module Grape
146
266
  end
147
267
 
148
268
  def run_rescue_handler(handler, error, endpoint, redispatched: false)
149
- handler = endpoint.public_method(handler) if handler.is_a?(Symbol)
269
+ callable = handler.is_a?(Symbol) ? endpoint.public_method(handler) : handler
150
270
  response = catch(:error) do
151
- handler.arity.zero? ? endpoint.instance_exec(&handler) : endpoint.instance_exec(error, &handler)
271
+ callable.arity.zero? ? endpoint.instance_exec(&callable) : endpoint.instance_exec(error, &callable)
152
272
  rescue StandardError => e
153
273
  return redispatch(e, endpoint, redispatched)
154
274
  end
@@ -191,7 +311,7 @@ module Grape
191
311
  # message. The framework deliberately does no logging of its own
192
312
  # here; that's the application's call.
193
313
  def safe_default(error, endpoint)
194
- env[Grape::Env::GRAPE_EXCEPTION] = error
314
+ expose_exception(error)
195
315
  return run_rescue_handler(internal_grape_exceptions_rescue_handler, error, endpoint, redispatched: true) if internal_grape_exceptions_rescue_handler
196
316
 
197
317
  framework_default(endpoint)
@@ -211,20 +331,7 @@ module Grape
211
331
  end
212
332
 
213
333
  def error?(response)
214
- case response
215
- when Grape::Exceptions::ErrorResponse
216
- true
217
- when Hash
218
- return false unless response.key?(:message) && response.key?(:status) && response.key?(:headers)
219
-
220
- Grape.deprecator.warn(
221
- 'Returning or throwing a Hash from a rescue handler is deprecated. ' \
222
- 'Use `error!(...)` or a `Grape::Exceptions::ErrorResponse` instead.'
223
- )
224
- true
225
- else
226
- false
227
- end
334
+ response.is_a?(Grape::Exceptions::ErrorResponse)
228
335
  end
229
336
  end
230
337
  end
@@ -3,24 +3,40 @@
3
3
  module Grape
4
4
  module Middleware
5
5
  class Formatter < Base
6
- extend Forwardable
7
6
  include PrecomputedContentTypes
8
7
 
9
8
  Options = Data.define(:content_types, :default_format, :format, :formatters, :parsers) do
10
- include Grape::Middleware::DeprecatedOptionsHashAccess
11
-
12
9
  def initialize(content_types: nil, default_format: :txt, format: nil, formatters: nil, parsers: nil)
13
10
  super
14
11
  end
15
12
  end
16
13
 
17
- # @deprecated Kept as a frozen Hash representation of the {Options}
18
- # defaults for back-compat. Will be removed in a future release.
19
- DEFAULT_OPTIONS = Options.new.to_h.freeze
20
-
21
14
  ALL_MEDIA_TYPES = '*/*'
22
15
 
23
- def_delegators :config, :default_format, :format, :formatters, :parsers
16
+ # The request methods that can carry a body worth parsing. See
17
+ # {#read_body_input?}, which tests the env against this before anything
18
+ # asks for a Rack::Request. QUERY is here because its content *is* the
19
+ # query (RFC 10008, Section 2), not an optional payload.
20
+ BODY_CARRYING_METHODS = [Rack::POST, Rack::PUT, Rack::PATCH, Rack::DELETE, Grape::QUERY].freeze
21
+
22
+ # Read off ivars rather than delegated into +config+ on every request:
23
+ # +negotiate_content_type+ asks for +format+ and +default_format+ per
24
+ # request and +fetch_formatter+ for +formatters+, and each delegator cost
25
+ # a Forwardable frame plus a Data reader for a value that was frozen when
26
+ # the middleware was built.
27
+ attr_reader :default_format, :format, :formatters, :parsers
28
+
29
+ # The formatter is the only middleware that maps an incoming media type
30
+ # back to a format, so it warms +mime_types+ itself rather than making
31
+ # every content-type-aware middleware build a table none of them read.
32
+ def initialize(app, **options)
33
+ super
34
+ @default_format = config.default_format
35
+ @format = config.format
36
+ @formatters = config.formatters
37
+ @parsers = config.parsers
38
+ mime_types
39
+ end
24
40
 
25
41
  def before
26
42
  negotiate_content_type
@@ -40,7 +56,7 @@ module Grape
40
56
  private
41
57
 
42
58
  def build_formatted_response(status, headers, bodies)
43
- headers = ensure_content_type(headers)
59
+ ensure_content_type!(headers)
44
60
 
45
61
  if bodies.is_a?(Grape::ServeStream::StreamResponse)
46
62
  Grape::ServeStream::SendfileResponse.new([], status, headers) do |resp|
@@ -52,7 +68,13 @@ module Grape
52
68
  bodymap = instrument_format_response(formatter) do
53
69
  bodies.map { |body| formatter.call(body, env) }
54
70
  end
55
- Rack::Response.new(bodymap, status, headers)
71
+ # A bare Rack tuple rather than a Rack::Response: +headers+ is already
72
+ # a Grape::Util::Header (a Rack::Headers on Rack 3), so wrapping only
73
+ # re-normalizes the same keys into a second Headers hash that
74
+ # +Middleware::Base#call+ unwraps again with +to_a+ on the way out.
75
+ # The 204/304 bodies Rack::Response#finish would blank are returned
76
+ # above, before this point.
77
+ [status, headers, bodymap]
56
78
  end
57
79
  rescue Grape::Exceptions::InvalidFormatter => e
58
80
  throw :error, Grape::Exceptions::ErrorResponse.new(status: 500, message: e.message, backtrace: e.backtrace, original_exception: e)
@@ -74,19 +96,23 @@ module Grape
74
96
 
75
97
  # Set the content type header for the API format if it is not already present.
76
98
  #
77
- # @param headers [Hash]
78
- # @return [Hash]
79
- def ensure_content_type(headers)
80
- return headers if headers[Rack::CONTENT_TYPE]
99
+ # Written into +headers+ rather than returned as a copy, hence the +!+:
100
+ # this runs on every response, and copying the hash costs an allocation
101
+ # per request for a header the caller is about to send anyway.
102
+ #
103
+ # @param headers [Hash] the response headers, mutated in place
104
+ # @return [void]
105
+ def ensure_content_type!(headers)
106
+ return if headers[Rack::CONTENT_TYPE]
81
107
 
82
108
  headers[Rack::CONTENT_TYPE] = content_type_for(env[Grape::Env::API_FORMAT])
83
- headers
84
109
  end
85
110
 
86
111
  def read_body_input
112
+ return unless read_body_input?
113
+
87
114
  input = rack_request.body # reads RACK_INPUT
88
115
  return if input.nil?
89
- return unless read_body_input?
90
116
 
91
117
  rewind = input.respond_to?(:rewind)
92
118
 
@@ -103,6 +129,13 @@ module Grape
103
129
  return if body.empty?
104
130
 
105
131
  media_type = rack_request.media_type
132
+
133
+ # RFC 10008, Sections 2 and 2.1: a QUERY carries its query in the
134
+ # content, so a request that never says what that content is cannot be
135
+ # interpreted and must fail rather than be read as the default format.
136
+ # Every other method keeps that fallback.
137
+ throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: 'The QUERY method requires a content-type.') if media_type.nil? && env[Rack::REQUEST_METHOD] == Grape::QUERY
138
+
106
139
  fmt = media_type ? mime_types[media_type] : default_format
107
140
 
108
141
  throw :error, Grape::Exceptions::ErrorResponse.new(status: 415, message: "The provided content-type '#{media_type}' is not supported.") unless content_type_for(fmt)
@@ -110,12 +143,12 @@ module Grape
110
143
  return env[Grape::Env::API_REQUEST_BODY] = body unless parser
111
144
 
112
145
  begin
113
- body = (env[Grape::Env::API_REQUEST_BODY] = parser.call(body, env))
114
- if body.is_a?(Hash)
146
+ parsed = (env[Grape::Env::API_REQUEST_BODY] = parser.call(body, env))
147
+ if parsed.is_a?(Hash)
115
148
  if (form_hash = env[Rack::RACK_REQUEST_FORM_HASH])
116
- form_hash.merge!(body)
149
+ form_hash.merge!(parsed)
117
150
  else
118
- env[Rack::RACK_REQUEST_FORM_HASH] = body
151
+ env[Rack::RACK_REQUEST_FORM_HASH] = parsed
119
152
  end
120
153
  env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT]
121
154
  end
@@ -133,34 +166,70 @@ module Grape
133
166
  # - multipart/related
134
167
  # - multipart/mixed
135
168
  def read_body_input?
136
- return false unless rack_request.post? || rack_request.put? || rack_request.patch? || rack_request.delete?
169
+ # Read off the env rather than through Rack::Request's predicates: this
170
+ # is what decides the question for every request, and on the GET, HEAD
171
+ # and OPTIONS majority it is the only thing the formatter would have
172
+ # built a Rack::Request for.
173
+ return false unless BODY_CARRYING_METHODS.include?(env[Rack::REQUEST_METHOD])
137
174
  return false if rack_request.form_data? && rack_request.content_type
138
175
  return false if rack_request.parseable_data?
139
176
 
140
- rack_request.content_length.to_i.positive? || rack_request.env['HTTP_TRANSFER_ENCODING'] == 'chunked'
177
+ rack_request.content_length.to_i.positive? || env['HTTP_TRANSFER_ENCODING'] == 'chunked'
141
178
  end
142
179
 
143
180
  def negotiate_content_type
144
- fmt = format_from_extension || query_params['format'] || format || format_from_header || default_format
181
+ fmt = format_from_extension || format_from_query || format || format_from_header || default_format
145
182
  return env[Grape::Env::API_FORMAT] = fmt.to_sym if content_type_for(fmt)
146
183
 
147
184
  throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, message: "The requested format '#{fmt}' is not supported.")
148
185
  end
149
186
 
187
+ # Only the extension is scrubbed, and only once the path turns out to have
188
+ # one: +String#rindex+ takes a byte offset and never raises on an invalid
189
+ # sequence, and a +.+ byte cannot be part of a multi-byte one, so the dot
190
+ # sits at the same place before and after scrubbing. The overwhelming
191
+ # majority of paths carry no extension and now skip the scrub entirely.
150
192
  def format_from_extension
151
- request_path = try_scrub(rack_request.path)
193
+ request_path = path_for_extension
152
194
  dot_pos = request_path.rindex('.')
153
195
  return unless dot_pos
154
196
 
155
- extension = request_path[(dot_pos + 1)..]
197
+ extension = try_scrub(request_path[(dot_pos + 1)..])
156
198
  extension if content_type_for(extension)
157
199
  end
158
200
 
201
+ # The extension is the tail of the request path, so PATH_INFO answers it
202
+ # on its own whenever there is one: a dot in SCRIPT_NAME is followed by
203
+ # the slash that opens PATH_INFO, and no registered extension holds a
204
+ # slash. Only an empty PATH_INFO needs +Rack::Request#path+ — and with it
205
+ # the String its concatenation allocates. Tested with +empty?+ rather
206
+ # than +blank?+: the path is not scrubbed yet, and a regexp match on an
207
+ # invalid byte sequence raises.
208
+ def path_for_extension
209
+ path_info = env[Rack::PATH_INFO]
210
+ return rack_request.path if path_info.nil? || path_info.empty?
211
+
212
+ path_info
213
+ end
214
+
215
+ # +?format=+ can only be there when there is a query string at all, so
216
+ # the common query-less request skips parsing one.
217
+ def format_from_query
218
+ query_string = env[Rack::QUERY_STRING]
219
+ return if query_string.nil? || query_string.empty?
220
+
221
+ query_params['format']
222
+ end
223
+
224
+ # Media types are case-insensitive (RFC 9110 §8.3.1) but the registered
225
+ # ones are spelled in lower case and Rack matches them literally, so an
226
+ # `Accept: TEXT/PLAIN` found nothing and fell through to the default
227
+ # format — the client quietly got something other than what it asked for.
159
228
  def format_from_header
160
229
  accept_header = try_scrub(env['HTTP_ACCEPT'])
161
230
  return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES
162
231
 
163
- media_type = Rack::Utils.best_q_match(accept_header, mime_types.keys)
232
+ media_type = Rack::Utils.best_q_match(accept_header.downcase, mime_types.keys)
164
233
  mime_types[media_type] if media_type
165
234
  end
166
235
  end
@@ -7,8 +7,14 @@ module Grape
7
7
  # +content_type+ resolved from +config.content_types+ and
8
8
  # +config.format+ — so the consuming middleware's +Options+ Data class
9
9
  # must declare both fields. Warms those caches on the parent instance
10
- # at initialization so per-request +dup+s inherit them (avoiding
11
- # ~1 µs/request of +with_indifferent_access+ recomputation).
10
+ # at initialization so per-request +dup+s inherit them rather than
11
+ # rebuilding them.
12
+ #
13
+ # +mime_types+ is not warmed here: Formatter is the only middleware that
14
+ # reads it, and it warms it itself. The tables behind +mime_types+ and
15
+ # +content_type_for+ are shared process-wide per content-type registry
16
+ # (see Grape::ContentTypes), so the ivars below memoize a lookup, not a
17
+ # copy.
12
18
  #
13
19
  # Opt-in: plain +Grape::Middleware::Base+ subclasses that don't need
14
20
  # content-type-aware helpers don't pay for them.
@@ -16,8 +22,7 @@ module Grape
16
22
  def initialize(app, **options)
17
23
  super
18
24
  content_types
19
- mime_types
20
- content_types_indifferent_access
25
+ content_types_lookup
21
26
  end
22
27
 
23
28
  def content_types
@@ -29,7 +34,7 @@ module Grape
29
34
  end
30
35
 
31
36
  def content_type_for(format)
32
- content_types_indifferent_access[format]
37
+ content_types_lookup[format]
33
38
  end
34
39
 
35
40
  def content_type
@@ -38,8 +43,8 @@ module Grape
38
43
 
39
44
  private
40
45
 
41
- def content_types_indifferent_access
42
- @content_types_indifferent_access ||= ActiveSupport::HashWithIndifferentAccess.new(content_types)
46
+ def content_types_lookup
47
+ @content_types_lookup ||= Grape::ContentTypes.lookup_for(content_types)
43
48
  end
44
49
  end
45
50
  end
@@ -30,6 +30,15 @@ module Grape
30
30
  end
31
31
  alias eql? ==
32
32
 
33
+ # Keyed on the wrapped class, so two Middleware wrapping the same
34
+ # class hash alike — and so does the class itself, which #== also
35
+ # accepts. The superclass fallback above can't be honoured here (a
36
+ # class and its superclass hash differently); it only ever serves
37
+ # #index / #include?, which compare with #== rather than by hash.
38
+ def hash
39
+ klass.hash
40
+ end
41
+
33
42
  def inspect
34
43
  klass.to_s
35
44
  end
@@ -55,15 +64,15 @@ module Grape
55
64
  end
56
65
 
57
66
  def insert(index, klass, *args, &block)
58
- index = assert_index(index, :before)
59
- middlewares.insert(index, self.class::Middleware.new(klass, args, block))
67
+ at = assert_index(index, :before)
68
+ middlewares.insert(at, self.class::Middleware.new(klass, args, block))
60
69
  end
61
70
 
62
71
  alias insert_before insert
63
72
 
64
73
  def insert_after(index, ...)
65
- index = assert_index(index, :after)
66
- insert(index + 1, ...)
74
+ at = assert_index(index, :after)
75
+ insert(at + 1, ...)
67
76
  end
68
77
 
69
78
  def use(klass, *args, &block)
@@ -8,22 +8,16 @@ module Grape
8
8
  include Grape::Middleware::PrecomputedContentTypes
9
9
 
10
10
  Options = Data.define(
11
- :content_types, :format, :mount_path, :pattern, :prefix, :version_options, :versions
11
+ :content_types, :format, :mount_path, :prefix, :version_options, :versions
12
12
  ) do
13
- include Grape::Middleware::DeprecatedOptionsHashAccess
14
-
15
13
  def initialize(
16
- content_types: nil, format: nil, mount_path: nil, pattern: /.*/i, prefix: nil,
14
+ content_types: nil, format: nil, mount_path: nil, prefix: nil,
17
15
  version_options: Grape::DSL::VersionOptions.new, versions: nil
18
16
  )
19
17
  super
20
18
  end
21
19
  end
22
20
 
23
- # @deprecated Kept as a frozen Hash representation of the {Options}
24
- # defaults for back-compat. Will be removed in a future release.
25
- DEFAULT_OPTIONS = Options.new.to_h.freeze
26
-
27
21
  CASCADE_PASS_HEADER = { 'X-Cascade' => 'pass' }.freeze
28
22
 
29
23
  def self.inherited(klass)
@@ -33,7 +27,7 @@ module Grape
33
27
 
34
28
  attr_reader :available_media_types, :error_headers, :versions
35
29
 
36
- def_delegators :config, :mount_path, :pattern, :prefix, :version_options
30
+ def_delegators :config, :mount_path, :prefix, :version_options
37
31
  def_delegators :version_options, :cascade, :parameter, :strict, :vendor
38
32
 
39
33
  def initialize(app, **options)