grape 3.2.1 → 3.3.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +116 -43
  4. data/UPGRADING.md +336 -1
  5. data/grape.gemspec +5 -5
  6. data/lib/grape/api/instance.rb +7 -7
  7. data/lib/grape/api.rb +22 -25
  8. data/lib/grape/cookies.rb +2 -6
  9. data/lib/grape/declared_params_handler.rb +48 -50
  10. data/lib/grape/dsl/callbacks.rb +9 -3
  11. data/lib/grape/dsl/desc.rb +8 -2
  12. data/lib/grape/dsl/entity.rb +88 -0
  13. data/lib/grape/dsl/helpers.rb +27 -7
  14. data/lib/grape/dsl/inside_route.rb +38 -129
  15. data/lib/grape/dsl/logger.rb +3 -5
  16. data/lib/grape/dsl/parameters.rb +32 -38
  17. data/lib/grape/dsl/request_response.rb +53 -48
  18. data/lib/grape/dsl/rescue_options.rb +24 -0
  19. data/lib/grape/dsl/routing.rb +51 -35
  20. data/lib/grape/dsl/settings.rb +14 -8
  21. data/lib/grape/dsl/version_options.rb +23 -0
  22. data/lib/grape/endpoint/options.rb +19 -0
  23. data/lib/grape/endpoint.rb +96 -68
  24. data/lib/grape/env.rb +1 -3
  25. data/lib/grape/error_formatter/base.rb +23 -20
  26. data/lib/grape/error_formatter/json.rb +8 -4
  27. data/lib/grape/error_formatter/txt.rb +10 -10
  28. data/lib/grape/exceptions/base.rb +3 -1
  29. data/lib/grape/exceptions/error_response.rb +45 -0
  30. data/lib/grape/exceptions/internal_server_error.rb +16 -0
  31. data/lib/grape/exceptions/validation.rb +14 -0
  32. data/lib/grape/exceptions/validation_array_errors.rb +4 -0
  33. data/lib/grape/exceptions/validation_errors.rb +12 -20
  34. data/lib/grape/formatter/serializable_hash.rb +5 -9
  35. data/lib/grape/json.rb +38 -2
  36. data/lib/grape/locale/en.yml +2 -0
  37. data/lib/grape/middleware/auth/base.rb +2 -3
  38. data/lib/grape/middleware/auth/dsl.rb +23 -8
  39. data/lib/grape/middleware/base.rb +22 -33
  40. data/lib/grape/middleware/deprecated_options_hash_access.rb +19 -0
  41. data/lib/grape/middleware/error.rb +152 -62
  42. data/lib/grape/middleware/formatter.rb +66 -50
  43. data/lib/grape/middleware/precomputed_content_types.rb +46 -0
  44. data/lib/grape/middleware/stack.rb +5 -6
  45. data/lib/grape/middleware/versioner/accept_version_header.rb +1 -1
  46. data/lib/grape/middleware/versioner/base.rb +34 -38
  47. data/lib/grape/middleware/versioner/header.rb +3 -5
  48. data/lib/grape/middleware/versioner/path.rb +8 -3
  49. data/lib/grape/namespace.rb +3 -3
  50. data/lib/grape/params_builder/hash_with_indifferent_access.rb +1 -1
  51. data/lib/grape/parser/json.rb +1 -1
  52. data/lib/grape/path.rb +14 -17
  53. data/lib/grape/request.rb +15 -8
  54. data/lib/grape/router/mustermann_pattern.rb +44 -0
  55. data/lib/grape/router/pattern.rb +6 -10
  56. data/lib/grape/router.rb +28 -42
  57. data/lib/grape/serve_stream/file_body.rb +1 -0
  58. data/lib/grape/serve_stream/sendfile_response.rb +3 -5
  59. data/lib/grape/serve_stream/stream_response.rb +1 -0
  60. data/lib/grape/testing.rb +33 -0
  61. data/lib/grape/util/base_inheritable.rb +13 -16
  62. data/lib/grape/util/inheritable_setting.rb +44 -27
  63. data/lib/grape/util/inheritable_values.rb +7 -3
  64. data/lib/grape/util/lazy/base.rb +16 -0
  65. data/lib/grape/util/lazy/block.rb +2 -9
  66. data/lib/grape/util/lazy/value.rb +2 -9
  67. data/lib/grape/util/lazy/value_enumerable.rb +13 -16
  68. data/lib/grape/util/media_type.rb +1 -4
  69. data/lib/grape/util/path_normalizer.rb +34 -0
  70. data/lib/grape/util/registry.rb +1 -1
  71. data/lib/grape/util/stackable_values.rb +11 -8
  72. data/lib/grape/validations/attributes_iterator.rb +13 -13
  73. data/lib/grape/validations/coerce_options.rb +21 -0
  74. data/lib/grape/validations/oneof_collector.rb +39 -0
  75. data/lib/grape/validations/param_scope_tracker.rb +14 -9
  76. data/lib/grape/validations/params_documentation.rb +25 -23
  77. data/lib/grape/validations/params_scope.rb +54 -172
  78. data/lib/grape/validations/shared_options.rb +19 -0
  79. data/lib/grape/validations/types/array_coercer.rb +2 -2
  80. data/lib/grape/validations/types/custom_type_coercer.rb +41 -85
  81. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  82. data/lib/grape/validations/types/dry_type_coercer.rb +3 -3
  83. data/lib/grape/validations/types/primitive_coercer.rb +10 -5
  84. data/lib/grape/validations/types/set_coercer.rb +1 -1
  85. data/lib/grape/validations/types/variant_collection_coercer.rb +8 -0
  86. data/lib/grape/validations/types.rb +23 -30
  87. data/lib/grape/validations/validations_spec.rb +149 -0
  88. data/lib/grape/validations/validators/all_or_none_of_validator.rb +1 -1
  89. data/lib/grape/validations/validators/at_least_one_of_validator.rb +1 -1
  90. data/lib/grape/validations/validators/base.rb +39 -22
  91. data/lib/grape/validations/validators/coerce_validator.rb +5 -3
  92. data/lib/grape/validations/validators/default_validator.rb +7 -8
  93. data/lib/grape/validations/validators/except_values_validator.rb +3 -2
  94. data/lib/grape/validations/validators/length_validator.rb +1 -1
  95. data/lib/grape/validations/validators/multiple_params_base.rb +10 -7
  96. data/lib/grape/validations/validators/oneof_validator.rb +49 -0
  97. data/lib/grape/validations/validators/values_validator.rb +5 -5
  98. data/lib/grape/version.rb +1 -1
  99. data/lib/grape/xml.rb +8 -1
  100. data/lib/grape.rb +6 -6
  101. metadata +34 -18
  102. data/lib/grape/middleware/globals.rb +0 -14
data/UPGRADING.md CHANGED
@@ -1,6 +1,341 @@
1
1
  Upgrading Grape
2
2
  ===============
3
3
 
4
+ ### Upgrading to >= 3.3
5
+
6
+ #### Minimum required Ruby is now 3.3
7
+
8
+ Grape no longer supports Ruby 3.2; 3.3 is now the minimum (`required_ruby_version = '>= 3.3'`). Upgrade your runtime to Ruby 3.3 or newer before bumping Grape.
9
+
10
+ #### `mustermann-grape` is no longer a dependency
11
+
12
+ Grape's path-pattern grammar (previously the `mustermann-grape` gem) now lives in Grape itself as `Grape::Router::MustermannPattern`, and Grape depends on `mustermann` directly. This is transparent for normal Grape usage.
13
+
14
+ The inlined class is no longer registered as a Mustermann type, so if your app called `Mustermann.new(pattern, type: :grape)` and relied on Grape loading `mustermann-grape` for you, add it to your Gemfile explicitly:
15
+
16
+ ```ruby
17
+ gem 'mustermann-grape'
18
+ ```
19
+
20
+ #### `Grape::Exceptions::ValidationErrors.new` keyword renamed `errors:` → `exceptions:`
21
+
22
+ `Grape::Exceptions::ValidationErrors#initialize` now takes its input array under the `exceptions:` keyword instead of `errors:`. The kwarg accepts a mix of `Grape::Exceptions::Validation` and `Grape::Exceptions::ValidationArrayErrors` instances; `ValidationArrayErrors` wrappers are flattened internally via `flat_map(&:errors)`. The `errors` reader on the constructed instance (the grouped `{params => [Validation, ...]}` Hash) is unchanged.
23
+
24
+ ```ruby
25
+ # before
26
+ Grape::Exceptions::ValidationErrors.new(errors: [validation, validation_array_errors], headers:)
27
+
28
+ # after
29
+ Grape::Exceptions::ValidationErrors.new(exceptions: [validation, validation_array_errors], headers:)
30
+ #### `Grape::Exceptions::ValidationErrors` no longer mixes in `Enumerable`
31
+
32
+ `Grape::Exceptions::ValidationErrors` no longer includes `Enumerable` and no longer defines a public `#each`. The Enumerable surface (`#each`, `#map`, `#select`, `#to_a`, etc.) was undocumented and untested; the documented accessors — `#errors`, `#full_messages`, `#message`, `#as_json` — are unchanged.
33
+
34
+ If a `rescue_from` block iterated over the exception instance, switch to `#errors`:
35
+
36
+ ```ruby
37
+ # before
38
+ rescue_from Grape::Exceptions::ValidationErrors do |e|
39
+ e.each { |attribute, error| ... }
40
+ end
41
+
42
+ # after
43
+ rescue_from Grape::Exceptions::ValidationErrors do |e|
44
+ e.errors.each do |attributes, errs|
45
+ errs.each { |error| ... }
46
+ end
47
+ end
48
+ ```
49
+
50
+ #### `rescue_from` rejects meta selectors mixed with exception classes
51
+
52
+ `rescue_from` used to silently drop additional exception classes when its first argument was a meta selector (`:all`, `:grape_exceptions`, `:internal_grape_exceptions`). It now raises `ArgumentError` so the misuse is caught at definition time:
53
+
54
+ ```ruby
55
+ # previously: MyError was silently dropped — only :all took effect
56
+ rescue_from :all, MyError, with: :handler
57
+
58
+ # now: ArgumentError ("rescue_from :all does not accept additional arguments")
59
+ # split into two declarations instead:
60
+ rescue_from :all, with: :handler
61
+ rescue_from MyError, with: :other_handler
62
+ ```
63
+
64
+ Calls that only use one meta selector or only use exception classes (the documented forms) are unaffected.
65
+
66
+ #### `auth`, `http_basic` and `http_digest` now take keyword arguments
67
+
68
+ `Grape::Middleware::Auth::DSL#auth`, `#http_basic` and `#http_digest` now accept their options as keyword arguments instead of a positional `Hash`. Calls using bare keyword syntax or a block are unaffected:
69
+
70
+ ```ruby
71
+ http_basic realm: 'API' do |u, p|
72
+ # ...
73
+ end
74
+
75
+ auth :http_digest, realm: 'API', opaque: 'secret', &proc
76
+ ```
77
+
78
+ Passing a positional options `Hash` still works but is deprecated and will be removed in a future release:
79
+
80
+ ```ruby
81
+ # deprecated
82
+ http_basic({ realm: 'API' })
83
+ auth :http_digest, { realm: 'API', opaque: 'secret' }
84
+
85
+ # preferred
86
+ http_basic(realm: 'API')
87
+ auth :http_digest, realm: 'API', opaque: 'secret'
88
+ ```
89
+
90
+ #### Middleware options now route through per-class `Options` `Data` value objects
91
+
92
+ `Grape::Middleware::Error`, `Grape::Middleware::Formatter`, and `Grape::Middleware::Versioner::Base` each declare an `Options` `Data.define` and route their `**options` kwargs through it on `initialize`. This means **unknown kwargs now raise `ArgumentError`** instead of being silently swallowed:
93
+
94
+ ```ruby
95
+ # previously: silently swallowed (Formatter doesn't actually read :rescue_options)
96
+ Grape::Middleware::Formatter.new(app, rescue_options: { backtrace: true })
97
+
98
+ # now: ArgumentError (unknown keyword: :rescue_options)
99
+ ```
100
+
101
+ Each `Options` class accepts exactly the kwargs the middleware actually reads. The supported sets:
102
+
103
+ - `Middleware::Error::Options`: `all_rescue_handler`, `base_only_rescue_handlers`, `content_types`, `default_error_formatter`, `default_message`, `default_status`, `error_formatters`, `format`, `grape_exceptions_rescue_handler`, `internal_grape_exceptions_rescue_handler`, `rescue_all`, `rescue_grape_exceptions`, `rescue_handlers`, `rescue_options`.
104
+ - `Middleware::Formatter::Options`: `content_types`, `default_format`, `format`, `formatters`, `parsers`.
105
+ - `Middleware::Versioner::Base::Options`: `content_types`, `format`, `mount_path`, `pattern`, `prefix`, `version_options`, `versions`.
106
+
107
+ The `Hash`-based `options` reader on `Grape::Middleware::Base` continues to return a frozen Hash representation of the Data (`config.to_h.freeze`) for back-compat with subclasses that read `options[:key]`. A new `config` reader exposes the typed Data instance — prefer the named accessors going forward:
108
+
109
+ ```ruby
110
+ # back-compat (still works)
111
+ options[:format]
112
+
113
+ # preferred
114
+ config.format
115
+ # or, on converted middlewares, just `format` (provided via def_delegators)
116
+ ```
117
+
118
+ `Options#[]` is defined as a Hash-style shim with a deprecation warning so legacy `data[:key]` callers get a migration nudge:
119
+
120
+ ```ruby
121
+ # emits Grape.deprecator warning
122
+ Grape::Middleware::Error::Options.new[:format]
123
+ ```
124
+
125
+ #### `DEFAULT_OPTIONS` constants on converted middlewares are deprecated
126
+
127
+ `Grape::Middleware::Error::DEFAULT_OPTIONS`, `Grape::Middleware::Formatter::DEFAULT_OPTIONS`, and `Grape::Middleware::Versioner::Base::DEFAULT_OPTIONS` still exist as a frozen `Hash` representation of the `Options` defaults (`Options.new.to_h.freeze`), for back-compat with any code that referenced these constants directly. They will be removed in a future release; introspect the `Options` `Data` class itself instead.
128
+
129
+ #### `Grape::Middleware::Globals` removed
130
+
131
+ `Grape::Middleware::Globals` and the three env constants it set (`Grape::Env::GRAPE_REQUEST`, `Grape::Env::GRAPE_REQUEST_HEADERS`, `Grape::Env::GRAPE_REQUEST_PARAMS`) have been deleted. The middleware was introduced in 2013 (commit `9987090b`) but never mounted by Grape's own stack — the `Grape::Request` it built is now constructed directly inside `Grape::Endpoint`. Nothing in `lib/` read those env keys.
132
+
133
+ If you mounted `Grape::Middleware::Globals` in your own Rack stack to populate `env['grape.request']` for downstream middleware, replicate it locally:
134
+
135
+ ```ruby
136
+ class MyGlobals
137
+ def initialize(app); @app = app; end
138
+
139
+ def call(env)
140
+ request = Grape::Request.new(env)
141
+ env['grape.request'] = request
142
+ env['grape.request.headers'] = request.headers
143
+ env['grape.request.params'] = request.params if env['rack.input']
144
+ @app.call(env)
145
+ end
146
+ end
147
+ ```
148
+
149
+ The original implementation is preserved in git history at [`6b4111b3:lib/grape/middleware/globals.rb`](https://github.com/ruby-grape/grape/blob/6b4111b3/lib/grape/middleware/globals.rb).
150
+
151
+ #### `error_formatter` now receives a `Grape::Exceptions::ErrorResponse` value object
152
+
153
+ Custom error formatters now receive a frozen `Grape::Exceptions::ErrorResponse` as the `error:` keyword argument, alongside three request-time context kwargs. The new signature:
154
+
155
+ ```ruby
156
+ def call(error:, env: nil, include_backtrace: false, include_original_exception: false)
157
+ ```
158
+
159
+ `error` is the same value object the middleware uses internally, with `status` / `message` / `headers` / `backtrace` / `original_exception` accessors. The two `include_*` booleans are forwarded from the matching `rescue_from` options (previously buried inside `options[:rescue_options]`).
160
+
161
+ Existing positional formatters break and need to be updated:
162
+
163
+ ```ruby
164
+ # Before
165
+ error_formatter :txt, ->(message, backtrace, options, env, original_exception) { ... }
166
+
167
+ module CustomFormatter
168
+ def self.call(message, backtrace, options, env, original_exception)
169
+ ...
170
+ end
171
+ end
172
+
173
+ # After — pick fields off `error`
174
+ error_formatter :txt, ->(error:, **) { "[#{error.status}] #{error.message}" }
175
+
176
+ module CustomFormatter
177
+ def self.call(error:, **)
178
+ { status: error.status, message: error.message, backtrace: error.backtrace }
179
+ end
180
+ end
181
+ ```
182
+
183
+ Migration:
184
+
185
+ | Old positional arg | New |
186
+ | --- | --- |
187
+ | `message` | `error.message` |
188
+ | `backtrace` | `error.backtrace` |
189
+ | `original_exception` | `error.original_exception` |
190
+ | `options[:rescue_options][:backtrace]` | `include_backtrace` (kwarg) |
191
+ | `options[:rescue_options][:original_exception]` | `include_original_exception` (kwarg) |
192
+ | `env` | `env` (kwarg, still passed) |
193
+ | HTTP status | `error.status` (newly exposed) |
194
+ | Response headers | `error.headers` (newly exposed) |
195
+
196
+ The remaining middleware-options keys (`default_status`, `format`, `rescue_handlers`, …) were framework-internal and have never been part of the documented contract.
197
+
198
+ The change resolves [#2527](https://github.com/ruby-grape/grape/issues/2527): the HTTP `status` and the response `headers` are now part of the formatter contract, so JSON:API–style error bodies (which embed the status code) and header-aware formatters can be written without reaching into `env[Grape::Env::API_ENDPOINT]`.
199
+
200
+ #### `version` now takes explicit keyword arguments
201
+
202
+ `version` previously accepted `**options` and silently ignored any keys it didn't use. It now declares its options explicitly:
203
+
204
+ ```ruby
205
+ def version(*args, using: :path, cascade: true, parameter: 'apiver', strict: false, vendor: nil, &block)
206
+ ```
207
+
208
+ Passing an unrecognised keyword now raises `ArgumentError` instead of being swallowed. The most common offender is `format:` — it was never a `version` option (response format is set with `format`/`default_format`, and header-versioned requests carry the format in their `Accept` header), but the old splat let `version 'v1', using: :header, vendor: 'x', format: :json` through as a no-op.
209
+
210
+ ```ruby
211
+ # Before — `format:` silently ignored
212
+ version 'v1', using: :header, vendor: 'x', format: :json
213
+
214
+ # After
215
+ version 'v1', using: :header, vendor: 'x' # set responses with `format :json` / `default_format :json`
216
+ ```
217
+
218
+ Recognized keys are `using:`, `cascade:`, `parameter:`, `strict:`, `vendor:`. Calls using only those are unaffected.
219
+
220
+ #### `Grape::Middleware::Base#options` is now frozen
221
+
222
+ `@options` is frozen at the end of `Grape::Middleware::Base#initialize` (after `merge_default_options`). The hash is initialized once and treated as immutable for the lifetime of the middleware. Custom middleware that mutates `options[...]` at runtime will now raise `FrozenError`.
223
+
224
+ If your custom middleware was patching its own options on the fly:
225
+
226
+ ```ruby
227
+ # Before
228
+ class MyMiddleware < Grape::Middleware::Base
229
+ def before
230
+ options[:flag] = compute_flag
231
+ # ...
232
+ end
233
+ end
234
+
235
+ # After — store mutable runtime state on a dedicated ivar
236
+ class MyMiddleware < Grape::Middleware::Base
237
+ def before
238
+ @flag = compute_flag
239
+ # ...
240
+ end
241
+ end
242
+ ```
243
+
244
+ Reading `options[...]` is unchanged.
245
+
246
+ #### Throw `:error` payloads are now `Grape::Exceptions::ErrorResponse`
247
+
248
+ The payload thrown via `throw :error, ...` is now a `Grape::Exceptions::ErrorResponse` value object instead of a `Hash`. If you `catch(:error)` and inspect the payload, switch from `payload[:status]` to `payload.status` (or `payload[:message]` to `payload.message`, etc.). User-defined `throw :error, hash` calls continue to work — `Middleware::Error#error_response` coerces Hashes, exceptions, and `ErrorResponse` instances at the boundary.
249
+
250
+ Returning or throwing a `Hash` with `:message`, `:status`, and `:headers` from a `rescue_from` handler is now deprecated and will be removed in a future release. Use `error!(...)` or return/throw a `Grape::Exceptions::ErrorResponse` instead.
251
+
252
+ #### `Grape::Request#grape_routing_args` has been removed
253
+
254
+ `grape_routing_args` was previously public to support third-party `params_builder` extensions, which have since been removed. With no remaining callers, the method has been removed. If you were calling it externally, read `env[Grape::Env::GRAPE_ROUTING_ARGS]` directly.
255
+
256
+ #### `endpoint_run_filters.grape` notification no longer fired for empty filter lists
257
+
258
+ `ActiveSupport::Notifications` subscribers listening to `endpoint_run_filters.grape` will no longer receive an event when the filter list for a given phase (`:before`, `:before_validation`, `:after_validation`, `:after`, `:finally`) is empty. Previously every phase emitted an event on every request regardless of whether any filters were registered. If you relied on these events to infer per-phase timing, subscribe to `endpoint_run.grape` (which always fires once per request) or register a no-op filter to keep the phase instrumented.
259
+
260
+ #### `Grape::Endpoint.before_each` moved to `Grape::Testing`
261
+
262
+ `Grape::Endpoint.before_each` and `Grape::Endpoint.reset_before_each` are now only available after requiring `grape/testing`. This module is intended for test environments only and is not loaded by default.
263
+
264
+ Add the following to your test helper:
265
+
266
+ ```ruby
267
+ require 'grape/testing'
268
+ ```
269
+
270
+ The `before_each` method now always requires a block — calling it without one raises `ArgumentError`. To clear registered hooks, use the new dedicated `reset_before_each` method:
271
+
272
+ ```ruby
273
+ # Before
274
+ after { Grape::Endpoint.before_each nil }
275
+
276
+ # After
277
+ after { Grape::Endpoint.reset_before_each }
278
+ ```
279
+
280
+ #### `Grape::Endpoint#logger` now returns the API's configured logger
281
+
282
+ Calling `logger` inside a route handler, filter (`before` / `before_validation` / `after_validation` / `after` / `finally`), or `rescue_from` block previously raised `NoMethodError` unless the application defined a helper:
283
+
284
+ ```ruby
285
+ class MyAPI < Grape::API
286
+ logger Logger.new($stdout)
287
+
288
+ helpers do
289
+ def logger
290
+ MyAPI.logger
291
+ end
292
+ end
293
+ end
294
+ ```
295
+
296
+ `Grape::Endpoint` now exposes `#logger` directly, so the helper is no longer necessary:
297
+
298
+ ```ruby
299
+ class MyAPI < Grape::API
300
+ logger Logger.new($stdout)
301
+ # logger is now reachable inside route handlers, filters, and rescue_from blocks
302
+ end
303
+ ```
304
+
305
+ **Helper override still wins.** Helpers are mixed into the endpoint's singleton class via `singleton_class.include(@helpers)`, and singleton-class methods take precedence over instance methods on `Grape::Endpoint`. If your application already defines `logger` in a `helpers` block (or a module included via `helpers`), that definition continues to override `Endpoint#logger`. You can safely keep the helper or remove it — both paths produce the same result for the canonical `MyAPI.logger` case above.
306
+
307
+ **Behaviour change for code that didn't define a helper.** If your code references `logger` inside an endpoint context *without* a corresponding `helpers` definition, that call previously raised `NoMethodError` and now returns the API's configured logger. This is almost always the intended behaviour, but if you were relying on the `NoMethodError` (for instance to short-circuit logging in test environments via `rescue NoMethodError`), update your code to check `respond_to?(:logger)` or to gate logging on a feature flag.
308
+
309
+ #### Exceptions raised inside `rescue_from` blocks are now caught
310
+
311
+ Previously, an exception raised inside a `rescue_from` block was uncaught and bubbled up to Rack, producing the Rack default 500 page. The framework now catches and routes it:
312
+
313
+ 1. If the re-raised exception's class has a registered `rescue_from` handler, that handler runs (one redispatch only — a second raise stops the chain).
314
+ 2. If the re-raised exception is a `Grape::Exceptions::Base` subclass, it is rendered via the default Grape error path with its own `status` and `message`.
315
+ 3. Otherwise, the original exception is exposed on `env['grape.exception']` for upstream Rack middleware to observe, and the response is a generic `Grape::Exceptions::InternalServerError` (`500 Internal Server Error`) — the original exception's message is **not** rendered to the API consumer.
316
+
317
+ This means deliberate re-raises in a `rescue_from` block (e.g. translating one exception class into another) now compose with the rest of your `rescue_from` configuration, and accidental crashes (typos, `NoMethodError`, …) no longer leak internal detail to API consumers.
318
+
319
+ The framework deliberately does **not** log unhandled internal exceptions itself — formatting and destination are application concerns. To log, forward to an error tracker, or customize the response shape for these errors, register a `rescue_from :internal_grape_exceptions` handler:
320
+
321
+ ```ruby
322
+ rescue_from :internal_grape_exceptions do |e|
323
+ Sentry.capture_exception(e)
324
+ error!({ message: 'Something went wrong' }, 500)
325
+ end
326
+ ```
327
+
328
+ When this handler is registered, the framework hands the original exception to you and you own the response shape entirely.
329
+
330
+ If you relied on the old behaviour and want raw exception messages exposed in development, register a catch-all handler:
331
+
332
+ ```ruby
333
+ rescue_from StandardError do |e|
334
+ error!({ message: e.message, class: e.class.name }, 500)
335
+ end
336
+ ```
337
+
338
+
4
339
  ### Upgrading to >= 3.2
5
340
 
6
341
  #### Rack parameter parsing errors now raise `Grape::Exceptions::RequestError`
@@ -694,7 +1029,7 @@ class Api < Grape::API
694
1029
  params[:my_param]
695
1030
  end
696
1031
  get '/example', params: { my_param: nil }
697
- # 1.3.1 = []
1032
+ # 1.3.3 = []
698
1033
  # 1.3.2 = nil
699
1034
  end
700
1035
  ```
data/grape.gemspec CHANGED
@@ -21,13 +21,13 @@ Gem::Specification.new do |s|
21
21
  }
22
22
 
23
23
  s.add_dependency 'activesupport', '>= 7.2'
24
- s.add_dependency 'dry-configurable'
24
+ s.add_dependency 'dry-configurable', '>= 1.0'
25
25
  s.add_dependency 'dry-types', '>= 1.1'
26
- s.add_dependency 'mustermann-grape', '~> 1.1.0'
27
- s.add_dependency 'rack', '>= 2'
28
- s.add_dependency 'zeitwerk'
26
+ s.add_dependency 'mustermann', '>= 4.0'
27
+ s.add_dependency 'rack', '>= 2.2.4'
28
+ s.add_dependency 'zeitwerk', '>= 2.6'
29
29
 
30
30
  s.files = Dir['lib/**/*', 'CHANGELOG.md', 'CONTRIBUTING.md', 'README.md', 'grape.png', 'UPGRADING.md', 'LICENSE', 'grape.gemspec']
31
31
  s.require_paths = ['lib']
32
- s.required_ruby_version = '>= 3.2'
32
+ s.required_ruby_version = '>= 3.3'
33
33
  end
@@ -132,7 +132,7 @@ module Grape
132
132
  def cascade?
133
133
  namespace_inheritable = self.class.inheritable_setting.namespace_inheritable
134
134
  return namespace_inheritable[:cascade] if namespace_inheritable.key?(:cascade)
135
- return namespace_inheritable[:version_options][:cascade] if namespace_inheritable[:version_options]&.key?(:cascade)
135
+ return namespace_inheritable[:version_options].cascade if namespace_inheritable[:version_options]
136
136
 
137
137
  true
138
138
  end
@@ -168,25 +168,25 @@ module Grape
168
168
  allowed_methods |= [Rack::HEAD] if !namespace_inheritable[:do_not_route_head] && allowed_methods.include?(Rack::GET)
169
169
 
170
170
  allow_header = namespace_inheritable[:do_not_route_options] ? allowed_methods : [Rack::OPTIONS] | allowed_methods
171
- last_route.app.options[:options_route_enabled] = true unless namespace_inheritable[:do_not_route_options] || allowed_methods.include?(Rack::OPTIONS)
171
+ last_route.app.options_route_enabled = true unless namespace_inheritable[:do_not_route_options] || allowed_methods.include?(Rack::OPTIONS)
172
172
 
173
173
  greedy_route = Grape::Router::GreedyRoute.new(last_route.pattern, endpoint: last_route.app, allow_header:)
174
174
  @router.associate_routes(greedy_route)
175
175
  end
176
176
  end
177
177
 
178
- ROOT_PREFIX_VERSIONING_KEY = %i[version version_options root_prefix].freeze
179
- private_constant :ROOT_PREFIX_VERSIONING_KEY
178
+ ROOT_PREFIX_VERSIONING_KEYS = %i[version version_options root_prefix].freeze
179
+ private_constant :ROOT_PREFIX_VERSIONING_KEYS
180
180
 
181
181
  # Allows definition of endpoints that ignore the versioning configuration
182
182
  # used by the rest of your API.
183
183
  def without_root_prefix_and_versioning
184
184
  inheritable_setting = self.class.inheritable_setting
185
- deleted_values = inheritable_setting.namespace_inheritable.delete(*ROOT_PREFIX_VERSIONING_KEY)
185
+ deleted_values = inheritable_setting.namespace_inheritable.delete(*ROOT_PREFIX_VERSIONING_KEYS)
186
186
  yield
187
187
  ensure
188
- ROOT_PREFIX_VERSIONING_KEY.each_with_index do |key, index|
189
- inheritable_setting.namespace_inheritable[key] = deleted_values[index]
188
+ ROOT_PREFIX_VERSIONING_KEYS.zip(deleted_values) do |key, value|
189
+ inheritable_setting.namespace_inheritable[key] = value
190
190
  end
191
191
  end
192
192
  end
data/lib/grape/api.rb CHANGED
@@ -10,8 +10,11 @@ module Grape
10
10
  Helpers = Grape::DSL::Helpers::BaseHelper
11
11
 
12
12
  class Boolean
13
+ VALUES = [true, false].freeze
14
+ private_constant :VALUES
15
+
13
16
  def self.build(val)
14
- return nil if val != true && val != false
17
+ return unless VALUES.include?(val)
15
18
 
16
19
  new
17
20
  end
@@ -56,12 +59,10 @@ module Grape
56
59
  # `configuration` as normal.
57
60
  def configure
58
61
  config = @base_instance.configuration
59
- if block_given?
60
- yield config
61
- self
62
- else
63
- config
64
- end
62
+ return config unless block_given?
63
+
64
+ yield config
65
+ self
65
66
  end
66
67
 
67
68
  # The remountable class can have a configuration hash to provide some dynamic class-level variables.
@@ -69,11 +70,11 @@ module Grape
69
70
  # depending on where the endpoint is mounted. Use with care, if you find yourself using configuration
70
71
  # too much, you may actually want to provide a new API rather than remount it.
71
72
  def mount_instance(configuration: nil)
72
- Class.new(@base_parent).tap do |instance|
73
- instance.configuration = Grape::Util::EndpointConfiguration.new(configuration || {})
74
- instance.base = self
75
- replay_setup_on(instance)
76
- end
73
+ instance = Class.new(@base_parent)
74
+ instance.configuration = Grape::Util::EndpointConfiguration.new(configuration || {})
75
+ instance.base = self
76
+ replay_setup_on(instance)
77
+ instance
77
78
  end
78
79
 
79
80
  private
@@ -126,11 +127,10 @@ module Grape
126
127
  eval_args = evaluate_arguments(instance.configuration, *args)
127
128
  eval_kwargs = kwargs.deep_transform_values { |v| evaluate_arguments(instance.configuration, v).first }
128
129
  response = instance.__send__(method, *eval_args, **eval_kwargs, &block)
129
- if skip_immediate_run?(instance, [response], kwargs)
130
- response
131
- else
132
- evaluate_arguments(instance.configuration, response).first
133
- end
130
+
131
+ return response if skip_immediate_run?(instance, [response], kwargs)
132
+
133
+ evaluate_arguments(instance.configuration, response).first
134
134
  end
135
135
 
136
136
  # Skips steps that contain arguments to be lazily executed (on re-mount time)
@@ -140,26 +140,23 @@ module Grape
140
140
  end
141
141
 
142
142
  def any_lazy?(args)
143
- args.any? { |argument| argument_lazy?(argument) }
143
+ args.any?(Grape::Util::Lazy::Base)
144
144
  end
145
145
 
146
146
  def evaluate_arguments(configuration, *args)
147
147
  args.map do |argument|
148
- if argument_lazy?(argument)
148
+ case argument
149
+ when Grape::Util::Lazy::Base
149
150
  argument.evaluate_from(configuration)
150
- elsif argument.is_a?(Hash)
151
+ when Hash
151
152
  argument.transform_values { |value| evaluate_arguments(configuration, value).first }
152
- elsif argument.is_a?(Array)
153
+ when Array
153
154
  evaluate_arguments(configuration, *argument)
154
155
  else
155
156
  argument
156
157
  end
157
158
  end
158
159
  end
159
-
160
- def argument_lazy?(argument)
161
- argument.respond_to?(:lazy?) && argument.lazy?
162
- end
163
160
  end
164
161
  end
165
162
  end
data/lib/grape/cookies.rb CHANGED
@@ -13,7 +13,7 @@ module Grape
13
13
  def_delegators :cookies, :[], :each
14
14
 
15
15
  def initialize(rack_cookies)
16
- @cookies = rack_cookies
16
+ @cookies = ActiveSupport::HashWithIndifferentAccess.new(rack_cookies)
17
17
  @send_cookies = nil
18
18
  end
19
19
 
@@ -37,11 +37,7 @@ module Grape
37
37
 
38
38
  private
39
39
 
40
- def cookies
41
- return @cookies unless @cookies.is_a?(Proc)
42
-
43
- @cookies = @cookies.call.with_indifferent_access
44
- end
40
+ attr_reader :cookies
45
41
 
46
42
  def send_cookies
47
43
  @send_cookies ||= Set.new
@@ -50,69 +50,67 @@ module Grape
50
50
  end
51
51
 
52
52
  def declared_hash_attr(passed_params, declared_param:, params_nested_path:, memo:, renamed_params:, route_params:)
53
- if declared_param.is_a?(Hash)
54
- declared_param.each_pair do |declared_parent_param, declared_children_params|
55
- next unless @include_missing || passed_params.key?(declared_parent_param)
56
-
57
- memo_key = build_memo_key(params_nested_path, declared_parent_param, renamed_params)
58
- passed_children_params = passed_params[declared_parent_param] || passed_params.class.new
59
-
60
- params_nested_path_dup = params_nested_path.dup
61
- params_nested_path_dup << declared_parent_param.to_s
62
-
63
- memo[memo_key] = handle_passed_param(params_nested_path_dup, route_params:, has_passed_children: passed_children_params.any?) do
64
- recursive_declared(
65
- passed_children_params,
66
- declared_params: declared_children_params,
67
- params_nested_path: params_nested_path_dup,
68
- renamed_params:,
69
- route_params:
70
- )
71
- end
72
- end
73
- else
74
- # If it is not a Hash then it does not have children.
75
- # Find its value or set it to nil.
76
- return unless @include_missing || (passed_params.respond_to?(:key?) && passed_params.key?(declared_param))
77
-
78
- memo_key = build_memo_key(params_nested_path, declared_param, renamed_params)
79
- passed_param = passed_params[declared_param]
80
-
81
- params_nested_path_dup = params_nested_path.dup
82
- params_nested_path_dup << declared_param.to_s
83
-
84
- memo[memo_key] = passed_param || handle_passed_param(params_nested_path_dup, route_params:) do
85
- passed_param
86
- end
53
+ return declare_leaf(passed_params, declared_param:, params_nested_path:, memo:, renamed_params:, route_params:) unless declared_param.is_a?(Hash)
54
+
55
+ declared_param.each_pair do |parent, children|
56
+ declare_nested(passed_params, parent:, children:, params_nested_path:, memo:, renamed_params:, route_params:)
87
57
  end
88
58
  end
89
59
 
90
- def build_memo_key(params_nested_path, declared_param, renamed_params)
91
- rename_path = params_nested_path + [declared_param.to_s]
92
- renamed_param_name = renamed_params[rename_path]
60
+ def declare_nested(passed_params, parent:, children:, params_nested_path:, memo:, renamed_params:, route_params:)
61
+ return unless @include_missing || passed_params.key?(parent)
62
+
63
+ memo_key = build_memo_key(params_nested_path, parent, renamed_params)
64
+ passed_children = passed_params[parent] || passed_params.class.new
65
+ nested_path = nested_path_for(params_nested_path, parent)
66
+
67
+ memo[memo_key] = handle_passed_param(nested_path, route_params:, has_passed_children: passed_children.any?) do
68
+ recursive_declared(
69
+ passed_children,
70
+ declared_params: children,
71
+ params_nested_path: nested_path,
72
+ renamed_params:,
73
+ route_params:
74
+ )
75
+ end
76
+ end
77
+
78
+ # The declared param has no children. Find its value or set it to nil.
79
+ def declare_leaf(passed_params, declared_param:, params_nested_path:, memo:, renamed_params:, route_params:)
80
+ return unless @include_missing || (passed_params.respond_to?(:key?) && passed_params.key?(declared_param))
93
81
 
82
+ memo_key = build_memo_key(params_nested_path, declared_param, renamed_params)
83
+ passed_param = passed_params[declared_param]
84
+
85
+ memo[memo_key] = passed_param || handle_passed_param(nested_path_for(params_nested_path, declared_param), route_params:) do
86
+ passed_param
87
+ end
88
+ end
89
+
90
+ def build_memo_key(params_nested_path, declared_param, renamed_params)
91
+ renamed_param_name = renamed_params[nested_path_for(params_nested_path, declared_param)]
94
92
  param = renamed_param_name || declared_param
95
93
  @stringify ? param.to_s : param.to_sym
96
94
  end
97
95
 
98
- def handle_passed_param(params_nested_path, route_params:, has_passed_children: false, &_block)
96
+ def nested_path_for(parent_path, key)
97
+ parent_path + [key.to_s]
98
+ end
99
+
100
+ def handle_passed_param(params_nested_path, route_params:, has_passed_children: false)
99
101
  return yield if has_passed_children
100
102
 
101
- key = params_nested_path[0]
103
+ key = params_nested_path.first
102
104
  key += "[#{params_nested_path[1..].join('][')}]" if params_nested_path.size > 1
103
105
 
104
106
  type = route_params.dig(key, :type)
105
- has_children = route_params.keys.any? { |k| k != key && k.start_with?("#{key}[") }
106
-
107
- if type == 'Hash' && !has_children
108
- {}
109
- elsif type == 'Array' || (type&.start_with?('[') && !type.include?(','))
110
- []
111
- elsif type == 'Set' || type&.start_with?('#<Set', 'Set')
112
- Set.new
113
- else
114
- yield
115
- end
107
+ return yield if type.nil?
108
+
109
+ return {} if type == 'Hash' && route_params.keys.none? { |k| k != key && k.start_with?("#{key}[") }
110
+ return [] if type == 'Array' || (type.start_with?('[') && !type.include?(','))
111
+ return Set.new if type == 'Set' || type.start_with?('#<Set', 'Set')
112
+
113
+ yield
116
114
  end
117
115
  end
118
116
  end
@@ -9,9 +9,15 @@ module Grape
9
9
  # after: execute the given block after the endpoint code has run except in unsuccessful
10
10
  # finally: execute the given block after the endpoint code even if unsuccessful
11
11
 
12
- %w[before before_validation after_validation after finally].each do |callback_method|
13
- define_method callback_method.to_sym do |&block|
14
- inheritable_setting.namespace_stackable[callback_method.pluralize.to_sym] = block
12
+ {
13
+ before: :befores,
14
+ before_validation: :before_validations,
15
+ after_validation: :after_validations,
16
+ after: :afters,
17
+ finally: :finallies
18
+ }.each do |method_name, plural_key|
19
+ define_method method_name do |&block|
20
+ inheritable_setting.namespace_stackable[plural_key] = block
15
21
  end
16
22
  end
17
23
  end