grape 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -1
  3. data/README.md +195 -95
  4. data/UPGRADING.md +364 -0
  5. data/grape.gemspec +1 -1
  6. data/lib/grape/api/instance.rb +57 -30
  7. data/lib/grape/api.rb +52 -18
  8. data/lib/grape/content_types.rb +26 -9
  9. data/lib/grape/cookies.rb +1 -1
  10. data/lib/grape/declared_params_handler.rb +35 -7
  11. data/lib/grape/dsl/declared.rb +5 -4
  12. data/lib/grape/dsl/desc.rb +1 -2
  13. data/lib/grape/dsl/entity.rb +35 -19
  14. data/lib/grape/dsl/headers.rb +3 -3
  15. data/lib/grape/dsl/helpers.rb +5 -8
  16. data/lib/grape/dsl/inside_route.rb +33 -27
  17. data/lib/grape/dsl/logger.rb +10 -4
  18. data/lib/grape/dsl/middleware.rb +4 -9
  19. data/lib/grape/dsl/mounting.rb +149 -0
  20. data/lib/grape/dsl/parameters.rb +78 -104
  21. data/lib/grape/dsl/request_response.rb +35 -22
  22. data/lib/grape/dsl/routing.rb +17 -191
  23. data/lib/grape/dsl/settings.rb +1 -1
  24. data/lib/grape/dsl/version_options.rb +2 -2
  25. data/lib/grape/dsl/versioning.rb +98 -0
  26. data/lib/grape/endpoint/state.rb +15 -0
  27. data/lib/grape/endpoint.rb +229 -137
  28. data/lib/grape/error_formatter/base.rb +28 -13
  29. data/lib/grape/error_formatter/json.rb +3 -0
  30. data/lib/grape/error_formatter/jsonl.rb +15 -0
  31. data/lib/grape/error_formatter/xml.rb +3 -1
  32. data/lib/grape/exceptions/error_response.rb +23 -7
  33. data/lib/grape/exceptions/halt.rb +39 -0
  34. data/lib/grape/exceptions/validation.rb +9 -9
  35. data/lib/grape/exceptions/validation_errors.rb +12 -2
  36. data/lib/grape/formatter/jsonl.rb +34 -0
  37. data/lib/grape/formatter/markdown.rb +17 -0
  38. data/lib/grape/formatter/md.rb +11 -0
  39. data/lib/grape/locale/en.yml +1 -0
  40. data/lib/grape/middleware/auth/base.rb +16 -1
  41. data/lib/grape/middleware/auth/strategy_info.rb +2 -2
  42. data/lib/grape/middleware/base.rb +19 -13
  43. data/lib/grape/middleware/error.rb +133 -35
  44. data/lib/grape/middleware/formatter.rb +235 -94
  45. data/lib/grape/middleware/precomputed_content_types.rb +14 -2
  46. data/lib/grape/middleware/stack.rb +7 -8
  47. data/lib/grape/middleware/versioner/base.rb +26 -10
  48. data/lib/grape/middleware/versioner/header.rb +70 -15
  49. data/lib/grape/middleware/versioner/param.rb +3 -5
  50. data/lib/grape/middleware/versioner/path.rb +20 -11
  51. data/lib/grape/middleware/versioner.rb +1 -1
  52. data/lib/grape/mountable.rb +2 -4
  53. data/lib/grape/namespace.rb +2 -3
  54. data/lib/grape/parser/jsonl.rb +19 -0
  55. data/lib/grape/request.rb +46 -8
  56. data/lib/grape/router/base_route.rb +41 -0
  57. data/lib/grape/router/greedy_route.rb +1 -1
  58. data/lib/grape/router/mustermann_pattern.rb +1 -1
  59. data/lib/grape/router/pattern/path.rb +1 -1
  60. data/lib/grape/router/pattern.rb +11 -5
  61. data/lib/grape/router/route.rb +42 -11
  62. data/lib/grape/router/route_buckets.rb +246 -0
  63. data/lib/grape/router.rb +262 -32
  64. data/lib/grape/util/endpoint_configuration.rb +7 -1
  65. data/lib/grape/util/inheritable_setting.rb +80 -161
  66. data/lib/grape/util/lazy/value.rb +52 -11
  67. data/lib/grape/util/media_type.rb +38 -15
  68. data/lib/grape/util/path_normalizer.rb +7 -2
  69. data/lib/grape/util/registry.rb +9 -2
  70. data/lib/grape/util/scrub.rb +33 -0
  71. data/lib/grape/util/translation.rb +6 -6
  72. data/lib/grape/validations/attributes_iterator.rb +73 -37
  73. data/lib/grape/validations/coerce_options.rb +1 -1
  74. data/lib/grape/validations/params_documentation.rb +15 -7
  75. data/lib/grape/validations/params_scope.rb +176 -43
  76. data/lib/grape/validations/types/custom_type_coercer.rb +2 -3
  77. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  78. data/lib/grape/validations/types/dry_type_coercer.rb +20 -12
  79. data/lib/grape/validations/types/multiple_type_coercer.rb +1 -2
  80. data/lib/grape/validations/types/primitive_coercer.rb +17 -0
  81. data/lib/grape/validations/types/variant_collection_coercer.rb +24 -10
  82. data/lib/grape/validations/types.rb +12 -18
  83. data/lib/grape/validations/validations_spec.rb +53 -22
  84. data/lib/grape/validations/validators/allow_blank_validator.rb +2 -2
  85. data/lib/grape/validations/validators/as_validator.rb +2 -3
  86. data/lib/grape/validations/validators/base.rb +124 -25
  87. data/lib/grape/validations/validators/coerce_validator.rb +2 -3
  88. data/lib/grape/validations/validators/default_validator.rb +18 -2
  89. data/lib/grape/validations/validators/except_values_validator.rb +16 -3
  90. data/lib/grape/validations/validators/hash_elements_validator.rb +34 -0
  91. data/lib/grape/validations/validators/length_validator.rb +24 -16
  92. data/lib/grape/validations/validators/multiple_params_base.rb +0 -4
  93. data/lib/grape/validations/validators/values_validator.rb +18 -24
  94. data/lib/grape/version.rb +1 -2
  95. data/lib/grape.rb +3 -0
  96. metadata +18 -11
  97. data/lib/grape/util/lazy/value_array.rb +0 -21
  98. data/lib/grape/util/lazy/value_enumerable.rb +0 -31
  99. data/lib/grape/util/lazy/value_hash.rb +0 -21
  100. data/lib/grape/validations/multiple_attributes_iterator.rb +0 -13
  101. data/lib/grape/validations/single_attribute_iterator.rb +0 -28
data/UPGRADING.md CHANGED
@@ -1,6 +1,370 @@
1
1
  Upgrading Grape
2
2
  ===============
3
3
 
4
+ ### Upgrading to >= 4.1.0
5
+
6
+ #### `Rack::Head` wraps the router instead of each endpoint's stack
7
+
8
+ `Rack::Head` used to be the first middleware in each endpoint's stack. That missed a Rack app mounted with `mount`, which the router calls directly, and a middleware inserted ahead of it with `insert 0`: a `HEAD` request to either got the full body back. A single `Rack::Head` around the router now strips the body of every `HEAD` response the API gives ([#3042](https://github.com/ruby-grape/grape/pull/3042)).
9
+
10
+ An endpoint's stack now starts with `Grape::Middleware::Error`, and three things follow from that:
11
+
12
+ * `insert_before Rack::Head` and `insert_after Rack::Head` raise `No such middleware to insert before: Rack::Head` (or `after`) when the API compiles. Use `insert 0` to put a middleware first.
13
+ * A middleware inserted by index lands one place further in. `insert 1` used to put it between `Rack::Head` and `Grape::Middleware::Error`; it now puts it inside `Grape::Middleware::Error`, so `rescue_from` handles what it raises. Use `insert 0` to keep it outside.
14
+ * A middleware inserted with `insert 0` now sees the full body of a `HEAD` response, as one added with `use` always has.
15
+
16
+ #### A `:jsonl` format you declared now gets Grape's JSON Lines formatter, parser and error formatter
17
+
18
+ Grape now renders, parses and reports errors in JSON Lines under `:jsonl` ([#3029](https://github.com/ruby-grape/grape/pull/3029)). An API that declared `content_type :jsonl, ...` itself, with nothing of its own registered for it, gets Grape's:
19
+
20
+ * A response body that is not a String renders as JSON Lines. A String is still served as it is.
21
+ * A request body is parsed into an Array in `env['api.request.body']`, where it used to be the raw String. Keep the String with `parser :jsonl, nil`.
22
+ * An error renders as a JSON line instead of through `default_error_formatter`.
23
+
24
+ A formatter, parser or error formatter you register for `:jsonl` with `formatter`, `parser` or `error_formatter` still takes precedence.
25
+
26
+ #### Formats named `:md` and `:markdown` have a built-in formatter
27
+
28
+ `Grape::Formatter::Md` renders the `:md` format and `Grape::Formatter::Markdown` the `:markdown` one ([#3025](https://github.com/ruby-grape/grape/pull/3025)). Neither is a default content type, so only an API that declares one is affected. See [Serving Markdown](README.md#serving-markdown).
29
+
30
+ * An API that declared `content_type :md` (or `:markdown`) without a matching `formatter` used to send its bodies unchanged. A String still is. An object answering `to_markdown` is now rendered with it, and any other object raises `Grape::Exceptions::InvalidFormatter`: it answers in the default format when the client negotiated Markdown, and `500` otherwise.
31
+ * `:md` and `:markdown` now name the same format. An API that declared one of them used to answer the other extension (and `?format=`) in its default format, or `406`; it now answers it in Markdown, under the name it declared.
32
+ * A `formatter :md` or `formatter :markdown` of your own still takes precedence.
33
+ * A formatter class of your own named `Md` or `Markdown` that inherits `Grape::Formatter::Base` now replaces Grape's globally, with a warning that `md` or `markdown` is already registered. Rename it, and declare it with `formatter :md, YourFormatter` (or `:markdown`) in the APIs that use it.
34
+
35
+ #### `logger` applies to the API it is set on
36
+
37
+ `logger` kept the logger in settings shared by the whole process, so setting it on one API replaced it on every other: two APIs that each set a logger both logged to whichever was set last.
38
+
39
+ A logger now belongs to the API it is set on, and to the APIs mounted under it that set none of their own ([#3024](https://github.com/ruby-grape/grape/pull/3024)). An API that sets none, and is mounted under none that does, still logs to a default shared by every such API, so a level set on that default reaches all of them as before.
40
+
41
+ ```ruby
42
+ class Users < Grape::API
43
+ logger Logger.new('users.log')
44
+ end
45
+
46
+ class Orders < Grape::API
47
+ logger Logger.new('orders.log')
48
+ end
49
+
50
+ # Users.logger Before: the orders.log logger After: the users.log logger
51
+ ```
52
+
53
+ If an API logged to a logger set on another API that does not mount it, set the logger on that API as well, or on an API that mounts both. Setting one on `Grape::API::Instance` no longer reaches the APIs you define.
54
+
55
+ #### `error!` raises `Grape::Exceptions::Halt` instead of throwing `:error`
56
+
57
+ `error!` used to `throw :error`, and a throw is not an exception. Active Record commits a transaction that a throw leaves, so `error!` inside `transaction` saved what the block had written while the client was told the request failed ([#2197](https://github.com/ruby-grape/grape/issues/2197)). A throw cannot leave the fiber or thread it was thrown in either, so `error!` inside an `Async` task answered 500 instead of its status.
58
+
59
+ `error!` now raises `Grape::Exceptions::Halt` ([#3023](https://github.com/ruby-grape/grape/pull/3023)), a `StandardError` carrying the same response (`#response`, also `#status` and `#headers`). Grape renders it as it rendered the thrown error, without handing it to a `rescue_from` handler, `rescue_from :all` included.
60
+
61
+ * A `rescue => e` or `rescue StandardError` around `error!` now catches it, whether in a route, a helper, a filter or a middleware added with `use`. Narrow the rescue, or let `Halt` through:
62
+
63
+ ```ruby
64
+ rescue Grape::Exceptions::Halt
65
+ raise
66
+ rescue StandardError => e
67
+ # ...
68
+ ```
69
+
70
+ * Code that wraps `error!` in `catch(:error)`, a spec for instance, now gets the exception. Rescue `Grape::Exceptions::Halt` and read `#response`, the `Grape::Exceptions::ErrorResponse` that used to be thrown.
71
+ * An `ActiveSupport::Notifications` subscriber to `endpoint_run.grape`, `endpoint_render.grape` or `endpoint_run_filters.grape` now finds `:exception` and `:exception_object` in the payload when `error!` ends the request, as it does for any exception.
72
+
73
+ `throw :error` from your own middleware or `rescue_from` handlers still works.
74
+
75
+ #### `Grape::Exceptions::ErrorResponse` is no longer a `Data`
76
+
77
+ Every error response builds at least two `ErrorResponse`s, and building a `Data` costs several times what building a plain object does. It is now a plain frozen class ([#3023](https://github.com/ruby-grape/grape/pull/3023)). It is still built with keywords, each optional, and still answers its readers.
78
+
79
+ It no longer copies itself with `#with`, compares by value (`==`, `eql?`, `hash`), converts with `#to_h`, lists `members`, takes positional arguments or takes part in pattern matching. Build a new one with `ErrorResponse.new` instead of calling `#with`. To check a payload, read its fields, for instance with `have_attributes(status: 404, message: 'Not Found')` in a spec.
80
+
81
+ #### `Grape::Middleware::Versioner::Header` answers a common Accept header without `#before`
82
+
83
+ `Middleware::Base#call` copied the header versioner for every request so that `#before` could keep the env and the Accept header it scrubs in instance variables. When the API is not strict and the Accept header is one the versioner worked out when it was built (a media type it declares, `*/*` or none), it now records the media type and calls the app from the one instance the stack built ([#3017](https://github.com/ruby-grape/grape/pull/3017)). Any other request still goes through `#before`.
84
+
85
+ A subclass that overrides `#before` is therefore not called for those requests. Override `#call` instead, and keep what a request needs in locals or in the env rather than in instance variables: the instance is shared by every request.
86
+
87
+ #### `Grape::Middleware::Auth::Base#call` no longer goes through `#call!`
88
+
89
+ `Middleware::Base#call` copied the auth middleware for every request so that `#call!` could keep the env in an instance variable, which only the strategy's block read, to find the endpoint that checks the credentials. The block now takes the endpoint from the request's env, and `#call` answers from the one instance the stack built ([#3018](https://github.com/ruby-grape/grape/pull/3018)). `#call!` still answers a request when called directly.
90
+
91
+ A subclass that overrides `#call!` is therefore no longer called for a request. Override `#call` instead, and keep what a request needs in locals or in the env rather than in instance variables: the instance is shared by every request. A strategy registered with `Grape::Middleware::Auth::Strategies.add` is a middleware of its own and is not affected.
92
+
93
+ #### `Grape::Middleware::Error#call` no longer goes through `#call!`
94
+
95
+ `Grape::Middleware::Error` is in every endpoint's stack, and `Middleware::Base#call` copied it for every request so that `#call!` could keep the env in an ivar, which only rendering an error reads. It now answers from the one instance the stack built, and copies itself only once there is an error to render ([#3016](https://github.com/ruby-grape/grape/pull/3016)). `#call!` still answers a request when called directly.
96
+
97
+ A subclass that overrides `#call!` to wrap every request is therefore no longer called for one. Override `#call` instead, and keep what a request needs in locals or in the env rather than in instance variables: the instance is shared by every request.
98
+
99
+ #### A blank element of an optional Array block is validated when others are not blank
100
+
101
+ An optional Array param given a block used to pass over every empty element, even one sent with others ([#3014](https://github.com/ruby-grape/grape/pull/3014)). So `[{"sku":"A"}, {}]` passed `requires :sku` inside the block, and `[{"sku":"A"}, null]` or `[{"sku":"A"}, ""]` reached the endpoint, where `params[:variants].map { |variant| variant[:sku] }` raised. Such an element is now validated like any other:
102
+
103
+ ```ruby
104
+ params do
105
+ optional :variants, type: Array do
106
+ requires :sku
107
+ end
108
+ end
109
+
110
+ # {"variants": [{"sku": "A"}, {}]}
111
+ # Before: 201, the second variant has no sku
112
+ # After: 400, variants[1][sku] is missing
113
+
114
+ # {"variants": [{"sku": "A"}, null]}
115
+ # Before: 201, the endpoint gets nil
116
+ # After: 400, variants[1] is invalid, variants[1][sku] is missing
117
+ ```
118
+
119
+ A blank element that is not a Hash is also rejected in a required Array param whose block declares only optional params: `{"tags": [" ", {"label": "y"}]}` against `requires :tags, type: Array do optional :label end` now answers `tags[0] is invalid`.
120
+
121
+ An Array whose elements are all blank is still passed over, as the whole param is when it is left out, and so is an optional Array scope left out of an element of an Array further up. To keep accepting placeholder elements, leave them out on the client, or declare the param without a block (`optional :variants, type: Array`) and check its elements in the endpoint.
122
+
123
+ #### A header version holding a hyphen is read whole
124
+
125
+ With `version ..., using: :header`, a version holding a hyphen, such as a date (`2024-06-20`) or `2.0-beta`, is now read whole ([#3013](https://github.com/ruby-grape/grape/pull/3013)). The Accept header was split at the version's last hyphen, so the request was routed to the right version but reported the wrong one:
126
+
127
+ ```
128
+ # version '2024-06-20', using: :header, vendor: 'acme'
129
+ # Accept: application/vnd.acme-2024-06-20+json
130
+ # Before
131
+ version # => "20"
132
+ env['api.vendor'] # => "acme-2024-06"
133
+ # After
134
+ version # => "2024-06-20"
135
+ env['api.vendor'] # => "acme"
136
+ ```
137
+
138
+ An undeclared version of that shape now fails with `Grape::Exceptions::InvalidVersionHeader` (`API version not found.`), where it failed with `Grape::Exceptions::InvalidAcceptHeader` (`API vendor not found.`).
139
+
140
+ #### An `Integer` param rejects a number with a fractional part
141
+
142
+ A param of `type: Integer` now rejects a number with a fractional part instead of truncating it ([#3011](https://github.com/ruby-grape/grape/pull/3011)). A JSON body's `2.99` became `2` and `-0.5` became `0`, while the same value sent as a string, `"2.99"` in a query, was already rejected. The same applies to `Array[Integer]` and `Set[Integer]` elements. A whole number such as `1.0` still becomes `1`.
143
+
144
+ ```
145
+ # {"quantity": 2.99} against `requires :quantity, type: Integer`
146
+ # Before
147
+ 201, params[:quantity] == 2
148
+ # After
149
+ 400, quantity is invalid
150
+ ```
151
+
152
+ `types: [Integer, Float]` now answers `2.99` for that body, as it already did for the query string, where it used to answer `2`. To keep accepting and truncating fractional numbers, declare the param `type: Float` and call `to_i` on it, or pass `coerce_with: ->(value) { Integer(value.to_f) }`.
153
+
154
+ #### A required param with `values` rejects `nil` in a nested scope
155
+
156
+ A `requires` param with `values:` now rejects `nil` in a Hash scope, an Array scope or a `given` block, not only at the root ([#3010](https://github.com/ruby-grape/grape/pull/3010)). So does a value that coerces to `nil`, such as `""` for `type: Integer`. Before, only the root scope rejected it, so `{"lines":[{"qty":null}]}` passed the declaration below and reached the endpoint with `qty` set to `nil`:
157
+
158
+ ```ruby
159
+ params do
160
+ requires :lines, type: Array do
161
+ requires :qty, type: Integer, values: 1..99
162
+ end
163
+ end
164
+ ```
165
+
166
+ That request now answers `400` with `lines[0][qty] does not have a valid value`. A nested param that is missing now reports that message after `is missing`, as a root param already did:
167
+
168
+ ```
169
+ # Before
170
+ address[country] is missing
171
+ # After
172
+ address[country] is missing, address[country] does not have a valid value
173
+ ```
174
+
175
+ A child scope whose optional parent is absent is still not validated. To keep accepting `nil`, declare the param with `optional` instead.
176
+
177
+ #### A mounted Rack app is an ANY route
178
+
179
+ A Rack app mounted with `mount` (a lambda, Sinatra, Rails engine, anything that is not a Grape API) is now routed as an ANY route, like `route :any`, and keeps the place it was mounted in ([#3012](https://github.com/ruby-grape/grape/pull/3012)). Before, its route carried the method `ANY`, which the router has no routes for. The app was reached only as the fallback that answers `405` and auto `OPTIONS`, and that had these effects:
180
+
181
+ * A `route :any, '*path'` catch-all took every request meant for the app, even one declared after the `mount`.
182
+ * The app's env carried `grape.allowed_methods` (`["OPTIONS", "ANY"]`).
183
+ * A method the app should have answered got `405` when a Grape route used the same path for another method.
184
+
185
+ ```ruby
186
+ mount RackApp => '/rack'
187
+ route(:any, '*path') { error!('not found', 404) }
188
+
189
+ # GET /rack/x Before: 404 from the catch-all After: RackApp
190
+ ```
191
+
192
+ ```ruby
193
+ mount RackApp => '/rack'
194
+ get('rack/status') { 'ok' }
195
+
196
+ # POST /rack/status Before: 405 (Allow: OPTIONS, GET, HEAD) After: RackApp
197
+ ```
198
+
199
+ The mount's route reports `request_method` as `'*'` instead of `'ANY'`. Code that picks mounted apps out of `API.routes` by that value should compare with `'*'`.
200
+
201
+ #### A validation error without a message says the param is invalid
202
+
203
+ A `Grape::Exceptions::Validation` created without a `message:` now carries the `:invalid` message key and the message `is invalid` ([#2993](https://github.com/ruby-grape/grape/pull/2993)). Before, its `message_key` was `nil`, and its message was the exception's class name, which went into the response. A custom validator with no `default_message_key` that calls `validation_error!(attr_name)` answered:
204
+
205
+ ```
206
+ # Before
207
+ number Grape::Exceptions::Validation
208
+ # After
209
+ number is invalid
210
+ ```
211
+
212
+ Code that checked `message_key` for `nil` to find such errors should check for `:invalid` instead. The message can be translated or overridden under `grape.errors.messages.invalid`.
213
+
214
+ #### A `requires` or `optional` block needs exactly one name
215
+
216
+ A `requires` or `optional` given a block now raises `ArgumentError` unless it names exactly one parameter ([#2991](https://github.com/ruby-grape/grape/pull/2991)). Both forms were accepted before, and both declared something other than what they read:
217
+
218
+ ```ruby
219
+ # No name: the block's params were declared one level up, and under
220
+ # `optional` they were still required.
221
+ optional type: Hash do
222
+ requires :x
223
+ end
224
+
225
+ # Several names: `b` was required, but only `a` got the block's params,
226
+ # and `b` was left out of `declared`.
227
+ requires :a, :b, type: Hash do
228
+ requires :x
229
+ end
230
+ ```
231
+
232
+ Name the one parameter the block describes, and repeat the block for each parameter that shares its shape:
233
+
234
+ ```ruby
235
+ requires :a, type: Hash do
236
+ requires :x
237
+ end
238
+ requires :b, type: Hash do
239
+ requires :x
240
+ end
241
+ ```
242
+ #### A query string Grape does not parse no longer decides the response
243
+
244
+ Grape parsed the query string of every request that had one, only to look for a `?format=` override ([#2972](https://github.com/ruby-grape/grape/pull/2972)). It now parses it only when the string could name that param, and leaves it to whoever reads the params otherwise.
245
+
246
+ A query string Rack refuses to parse -- nested past `Rack::Utils.param_depth_limit`, naming one key as both a value and a hash, or carrying more pairs than the parser accepts -- therefore no longer answers `400` on an endpoint that reads no params. Such a request is answered the way any other request for that route is:
247
+
248
+ ```ruby
249
+ get('/ping') { 'pong' }
250
+
251
+ # Before
252
+ GET /ping?a[a][a]...=1 # => 400
253
+ # After
254
+ GET /ping?a[a][a]...=1 # => 200
255
+ ```
256
+
257
+ An endpoint that reads the params -- one with a `params` block, or whose code asks for `params` -- still answers `400`, from the parse it asked for.
258
+
259
+ #### A `redirect` body you pass carries the API's content type
260
+
261
+ A body given to `redirect` with `body:` is rendered by the API's formatter, and the response now carries that format's content type instead of `text/plain` ([#2968](https://github.com/ruby-grape/grape/pull/2968)). On a JSON API, `redirect '/there', body: { message: 'moved' }` sent `{"message":"moved"}` labelled `text/plain`; it is now labelled `application/json`. The message Grape generates when no body is given is still plain text under `text/plain`.
262
+
263
+ A client that picked the redirect body's parser by its `Content-Type` now gets the format the body is in; one that relied on `text/plain` for every redirect sees the API's own content type on those that pass a body.
264
+
265
+ #### Every `middleware` entry ends in its block
266
+
267
+ Each entry `API.middleware` returns now ends in the block its `use`, `insert`, `insert_before` or `insert_after` was given, `nil` when there was none ([#2967](https://github.com/ruby-grape/grape/pull/2967)). A Proc passed as the last argument used to be taken for the block, so `use SomeMiddleware, ->(env) { ... }` built the middleware without its callable and raised `ArgumentError`.
268
+
269
+ ```ruby
270
+ use SomeMiddleware, 'abc'
271
+
272
+ # Before
273
+ API.middleware # => [[:use, SomeMiddleware, 'abc']]
274
+ # After
275
+ API.middleware # => [[:use, SomeMiddleware, 'abc', nil]]
276
+ ```
277
+
278
+ Code that reads these entries should take the last element as the block.
279
+
280
+ #### Minimum required Ruby is now 3.3.1
281
+
282
+ Grape no longer supports Ruby 3.3.0; 3.3.1 is now the minimum (`required_ruby_version = '>= 3.3.1'`) ([#2966](https://github.com/ruby-grape/grape/pull/2966)). Ruby 3.3.0 rejects forwarding an anonymous parameter (`*`, `**`, `&`) from inside a block, which Grape does, so Grape could not be loaded on it; 3.3.1 fixed that. Upgrade your runtime to Ruby 3.3.1 or newer before bumping Grape.
283
+
284
+ #### An Array param given a block rejects elements that are not a Hash
285
+
286
+ The block of `requires :items, type: Array do ... end` declares the keys of each element, and an element that is not a Hash now answers `400` with `items[1] is invalid` ([#2957](https://github.com/ruby-grape/grape/pull/2957)). It used to fail only where the block required a key of it, so a block whose params are all optional let a String, a number or a nested Array through to the endpoint, and an Array nested one level too deep in a nested Array scope passed validation and then made `declared` raise. An Array whose elements are all blank — `nil`, `''`, `false`, `[]`, `{}` — is still passed over; a blank element sent with others is not (see above).
287
+
288
+ Where a block does require a key, the element's own error now comes first:
289
+
290
+ ```
291
+ # Before
292
+ items[0][key] is missing
293
+ # After
294
+ items[0] is invalid, items[0][key] is missing
295
+ ```
296
+
297
+ An API that accepts elements of other shapes in the same list should declare the param without a block (`type: Array`) and check the elements itself.
298
+
299
+ #### A collection with multiple member types rejects what it cannot coerce
300
+
301
+ A param declared as a collection whose members may be of several types — `type: Array[Integer, String]`, or such a collection listed in `types:` — is now validated the way a collection of one type is ([#2955](https://github.com/ruby-grape/grape/pull/2955)). A value that is not an Array, and an Array holding a member none of the types accepts, answer `400` with `is invalid`. The first used to reach the endpoint as `nil`, even under `requires`, and the second with an internal `Grape::Validations::Types::InvalidValue` object where the member was. A `nil` value or an empty String is still `nil`.
302
+
303
+ A `coerce_with` method given to such a param is now handed every value, as it is for `types:`, instead of only an Array. A method that splits a String into the collection works now; one that only ever expected an Array may raise on a String, which answers `400`.
304
+ #### A route's failure entity no longer presents a String error message
305
+
306
+ An entity named for a status in a route's `failure` (or `http_codes`) is now applied to a structured `error!` message only — a Hash or an object — and a String message renders as it does without one ([#2953](https://github.com/ruby-grape/grape/pull/2953)). An entity exposing attributes cannot read any off a String, so `error!('Unauthorized', 401)` under `desc failure: [[401, 'Unauthorized', API::Error]]` raised while rendering and was answered with a `500`; it now answers `401` with the message. An entity written to take the String itself is no longer handed one; give it a Hash to expose from instead:
307
+
308
+ ```ruby
309
+ # Before
310
+ class API::Error < Grape::Entity
311
+ expose(:message) { |message, _options| message }
312
+ end
313
+ error!('Unauthorized', 401)
314
+
315
+ # After
316
+ class API::Error < Grape::Entity
317
+ expose :message
318
+ end
319
+ error!({ message: 'Unauthorized' }, 401)
320
+ ```
321
+ #### `lint!` checks every response once, from the API that is served
322
+
323
+ `lint!` and `Grape.config.lint` used to put `Rack::Lint` in each endpoint's stack, which missed two kinds of response: the 404 the router answers for a path nothing matches, and those of a Rack app mounted with `mount`, which the router calls directly. A single `Rack::Lint` around the router now checks every response the API gives ([#2960](https://github.com/ruby-grape/grape/pull/2960)). Two things follow from that.
324
+
325
+ `lint!` counts only at the top level of the API that is served. Declared inside a namespace, or in an API mounted into another one, it no longer does anything: declare it on the API you serve, or set `Grape.config.lint`.
326
+
327
+ A test suite that lints its API may start raising `Rack::Lint::LintError` where a mounted Rack app breaks the Rack SPEC — returning a frozen headers Hash, for instance — or where a spec calls the API with an incomplete env:
328
+
329
+ ```ruby
330
+ # Before: passed under Grape.config.lint
331
+ MyAPI.call({})
332
+
333
+ # After
334
+ MyAPI.call(Rack::MockRequest.env_for('/'))
335
+ ```
336
+ #### The header versioner's `api.*` env values are frozen
337
+
338
+ `version ..., using: :header` now answers the Accept headers most requests send from a table built once, so every request sending the same header is handed the same parsed media type ([#2936](https://github.com/ruby-grape/grape/pull/2936)). The strings it writes into the env — `api.type`, `api.subtype`, `api.vendor`, `api.version` and `api.format` — are therefore frozen, as is `Grape::Util::MediaType` itself. Code that altered one of them in place now raises `FrozenError`; build a new String instead:
339
+
340
+ ```ruby
341
+ # Before
342
+ env['api.version'] << '-beta'
343
+
344
+ # After
345
+ env['api.version'] = "#{env['api.version']}-beta"
346
+ ```
347
+
348
+ #### `Grape::Util::Lazy` no longer builds a tree of value wrappers
349
+
350
+ A configuration read is now a single `Grape::Util::Lazy::Value` holding the configuration it was read from and the path of keys walked into it, rather than a node in a tree mirroring the whole `with:` Hash ([#2950](https://github.com/ruby-grape/grape/pull/2950)). `Grape::Util::Lazy::ValueHash`, `Grape::Util::Lazy::ValueArray` and `Grape::Util::Lazy::ValueEnumerable` are gone, as are `Value#reached_by` and `Value#fetch`; `Value#access_keys` is now `Value#path`. The `configuration` DSL is unchanged — only code reaching into these internals is affected.
351
+
352
+ `Grape::Util::EndpointConfiguration#evaluate` also answers the configuration Hash itself now, instead of rebuilding a copy on each call. Mutating what `configuration` returns inside an endpoint therefore persists for later requests, where it used to be discarded:
353
+
354
+ ```ruby
355
+ # Before: mutated a throwaway copy
356
+ # After: mutates the configuration every later request reads
357
+ get '/x' do
358
+ configuration[:list] << 'item'
359
+ end
360
+
361
+ # Treat it as read-only, or copy it first
362
+ get '/x' do
363
+ list = configuration[:list].dup
364
+ list << 'item'
365
+ end
366
+ ```
367
+
4
368
  ### Upgrading to >= 4.0.0
5
369
 
6
370
  #### A positional options Hash is no longer accepted by `auth`, `http_basic` or `desc`
data/grape.gemspec CHANGED
@@ -29,5 +29,5 @@ Gem::Specification.new do |s|
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.3'
32
+ s.required_ruby_version = '>= 3.3.1'
33
33
  end
@@ -15,6 +15,8 @@ module Grape
15
15
  extend Grape::DSL::Middleware
16
16
  extend Grape::DSL::RequestResponse
17
17
  extend Grape::DSL::Routing
18
+ extend Grape::DSL::Mounting
19
+ extend Grape::DSL::Versioning
18
20
  extend Grape::DSL::Helpers
19
21
  extend Grape::Middleware::Auth::DSL
20
22
 
@@ -94,7 +96,6 @@ module Grape
94
96
  def inherited(subclass)
95
97
  super
96
98
  subclass.reset!
97
- subclass.logger logger.clone
98
99
  end
99
100
  end
100
101
 
@@ -111,23 +112,33 @@ module Grape
111
112
 
112
113
  @router.compile!
113
114
  @router.freeze
114
- @cascade = resolve_cascade
115
+ # One Rack::Head around the router strips the body of every HEAD
116
+ # response the API gives, including the router's own 404 and a mounted
117
+ # Rack app's, which go through no endpoint's stack. Rack::Lint goes
118
+ # outside it, where it sees the response that is served.
119
+ @app = Rack::Head.new(@router)
120
+ @app = Rack::Lint.new(@app) if lint?
121
+ @cascade = self.class.cascade
115
122
  end
116
123
 
117
124
  # Handle a request. See Rack documentation for what `env` is.
118
125
  def call(env)
119
- status, headers, response = @router.call(env)
120
- unless @cascade
121
- # +merge!+, not +merge+: the latter is a `dup` plus a `merge!`, so the
122
- # Header built on this line would be allocated only to be discarded.
123
- # The copy stays because +headers+ can come from a mounted Rack app,
124
- # which is free to hand back a frozen or shared Hash that the delete
125
- # below must not reach into.
126
- headers = Grape::Util::Header.new.merge!(headers)
127
- headers.delete('X-Cascade')
128
- end
129
-
130
- [status, headers, response]
126
+ response = @app.call(env)
127
+ return response if @cascade
128
+
129
+ headers = response[1]
130
+ # A Grape::Util::Header folds case the way the copy below does, so it can
131
+ # answer for it; any other Hash cannot, and still goes through the copy.
132
+ return response if headers.is_a?(Grape::Util::Header) && !headers.key?('X-Cascade')
133
+
134
+ # +merge!+, not +merge+: the latter is a `dup` plus a `merge!`, so the
135
+ # Header built on this line would be allocated only to be discarded.
136
+ # The copy stays because +headers+ can come from a mounted Rack app,
137
+ # which is free to hand back a frozen or shared Hash that the delete
138
+ # below must not reach into.
139
+ headers = Grape::Util::Header.new.merge!(headers)
140
+ headers.delete('X-Cascade')
141
+ [response[0], headers, response[2]]
131
142
  end
132
143
 
133
144
  # Some requests may return a HTTP 404 error if grape cannot find a matching
@@ -136,7 +147,7 @@ module Grape
136
147
  # looking for a matching route on other resources.
137
148
  #
138
149
  # In some applications (e.g. mounting grape on rails), one might need to trap
139
- # errors from reaching upstream. This is effectivelly done by unsetting
150
+ # errors from reaching upstream. This is effectively done by unsetting
140
151
  # X-Cascade. Default :cascade is true.
141
152
  #
142
153
  # Resolved in the constructor rather than per request: answering it walks
@@ -167,29 +178,45 @@ module Grape
167
178
  setting = self.class.inheritable_setting
168
179
  routes_by_regexp = all_routes.group_by(&:pattern_regexp)
169
180
 
170
- # Build the configuration based on the first endpoint and the collection of methods supported.
181
+ # Build the configuration from the collection of methods supported.
171
182
  routes_by_regexp.each_value do |routes|
172
183
  next if routes.any? { |route| route.request_method == '*' }
173
184
 
174
- last_route = routes.last # Most of the configuration is taken from the last endpoint
175
- allowed_methods = routes.map(&:request_method)
176
- allowed_methods |= [Rack::HEAD] if !setting.do_not_route_head? && allowed_methods.include?(Rack::GET)
185
+ associate_greedy_route(routes, setting)
186
+ end
187
+ end
177
188
 
178
- allow_header = setting.do_not_route_options? ? allowed_methods : [Rack::OPTIONS] | allowed_methods
179
- last_route.app.options_route_enabled = true unless setting.do_not_route_options? || allowed_methods.include?(Rack::OPTIONS)
189
+ # The greedy route answering 405 and auto-OPTIONS for the path +routes+
190
+ # share. Most of the configuration is taken from the last endpoint.
191
+ def associate_greedy_route(routes, setting)
192
+ last_route = routes.last
193
+ allowed_methods = allowed_methods_for(routes, setting)
180
194
 
181
- greedy_route = Grape::Router::GreedyRoute.new(last_route.pattern, endpoint: last_route.app, allow_header:)
182
- @router.associate_routes(greedy_route)
183
- end
195
+ allow_header = setting.do_not_route_options? ? allowed_methods : [Rack::OPTIONS] | allowed_methods
196
+ last_route.app.options_route_enabled = true unless setting.do_not_route_options? || allowed_methods.include?(Rack::OPTIONS)
197
+
198
+ greedy_route = Grape::Router::GreedyRoute.new(last_route.pattern, endpoint: last_route.app, allow_header:)
199
+ @router.associate_routes(greedy_route)
184
200
  end
185
201
 
186
- # Backs {#cascade?}; called once, from the constructor.
187
- def resolve_cascade
188
- setting = self.class.inheritable_setting
189
- return setting.cascade if setting.cascade_defined?
190
- return setting.version_options.cascade if setting.version_options
202
+ # The methods +routes+ answer, with HEAD alongside GET unless the API
203
+ # declared +do_not_route_head!+.
204
+ def allowed_methods_for(routes, setting)
205
+ allowed_methods = routes.map(&:request_method)
206
+ return allowed_methods if setting.do_not_route_head? || !allowed_methods.include?(Rack::GET)
207
+
208
+ allowed_methods | [Rack::HEAD]
209
+ end
191
210
 
192
- true
211
+ # +lint!+ declared at the top of this API, or Grape.config.lint: one
212
+ # Rack::Lint around the router then checks every response the API gives
213
+ # -- an endpoint's, the 404 the router answers itself, a mounted Rack
214
+ # app's -- exactly once. Linting inside each endpoint's stack instead
215
+ # missed the last two, and wrapping a second Rack::Lint around those
216
+ # breaks a middleware that calls +to_ary+ on the body, as Rack::ETag does.
217
+ # Called once, from the constructor.
218
+ def lint?
219
+ self.class.inheritable_setting.lint? || Grape.config.lint
193
220
  end
194
221
  end
195
222
  end
data/lib/grape/api.rb CHANGED
@@ -7,21 +7,28 @@ module Grape
7
7
  # Marks this and every subclass as a mountable Grape app (see Grape::Mountable).
8
8
  extend Grape::Mountable
9
9
 
10
- # Class methods that we want to call on the API rather than on the API object
11
- NON_OVERRIDABLE = %i[base= base_instance? call change! configuration compile! inherit_settings recognize_path reset! routes top_level_setting= top_level_setting].freeze
10
+ # Class methods that we want to call on the API rather than on the API object.
11
+ # +inherit_settings+ is protected on the base instance, and +.methods+ answers
12
+ # protected methods too, so it has to be named here for {.override_all_methods!}
13
+ # to leave it alone. +base+ is read on the API class itself whenever a mount
14
+ # is refreshed, so recording it would refresh every mount below it again.
15
+ NON_OVERRIDABLE = %i[base base= base_instance? call change! configuration compile! inherit_settings recognize_path reset! routes top_level_setting].freeze
16
+
17
+ # DSL methods that answer a setting when called with nothing to set -- no
18
+ # argument, keyword or block -- and change none. Such a call is a read, and
19
+ # is answered without being recorded: as a setup step it was replayed on
20
+ # every mount and, like any step, refreshed every mount made before it,
21
+ # which discards the compiled API, so a read after boot made the next
22
+ # request recompile it. +helpers+ is left out: called bare it includes the
23
+ # helpers in scope and calls +change!+ itself.
24
+ SETTING_READERS = %i[
25
+ auth cascade content_types default_error_formatter default_error_status default_format endpoints format
26
+ group inheritable_setting logger middleware namespace prefix resource resources segment version versions
27
+ ].freeze
12
28
 
13
29
  Helpers = Grape::DSL::Helpers::BaseHelper
14
30
 
15
- class Boolean
16
- VALUES = [true, false].freeze
17
- private_constant :VALUES
18
-
19
- def self.build(val)
20
- return unless VALUES.include?(val)
21
-
22
- new
23
- end
24
- end
31
+ Boolean = Class.new
25
32
 
26
33
  class << self
27
34
  extend Forwardable
@@ -30,12 +37,30 @@ module Grape
30
37
 
31
38
  delegate_missing_to :base_instance
32
39
 
33
- # This is the interface point between Rack and Grape; it accepts a request
34
- # from Rack and ultimately returns an array of three values: the status,
35
- # the headers, and the body. See [the rack specification]
40
+ # Every NON_OVERRIDABLE name the base instance answers is forwarded here
41
+ # rather than left to +delegate_missing_to+, which stays for the names
42
+ # this list cannot know: anything defined on Grape::API::Instance after
43
+ # an API class was created, since {.override_all_methods!} copies the
44
+ # methods it finds at that moment.
45
+ #
46
+ # +inherit_settings+ is left out: it is protected on the base instance, so
47
+ # a delegator would raise NoMethodError just as the missing one does.
48
+ # +call+ is written out below.
49
+ def_delegators :base_instance, :new, :configuration, :change!, :compile!, :recognize_path, :routes,
50
+ :base, :base=, :base_instance?, :reset!, :top_level_setting
51
+
52
+ # The interface point between Rack and Grape; it accepts a request from
53
+ # Rack and ultimately returns an array of three values: the status, the
54
+ # headers, and the body. See [the rack specification]
36
55
  # (https://github.com/rack/rack/blob/main/SPEC.rdoc) for more.
37
56
  # NOTE: This will only be called on an API directly mounted on RACK
38
- def_delegators :base_instance, :new, :configuration, :call, :change!, :compile!, :recognize_path, :routes
57
+ #
58
+ # Not delegated with the rest because it runs on every request, and a
59
+ # Forwardable delegator checks its target with +defined?+ and forwards
60
+ # through +...+ each time: about as much again as the call it makes.
61
+ def call(env)
62
+ base_instance.call(env)
63
+ end
39
64
 
40
65
  # Initialize the instance variables on the remountable class, and the base_instance
41
66
  # an instance that will be used to create the set up but will not be mounted
@@ -46,11 +71,16 @@ module Grape
46
71
  @base_instance = mount_instance
47
72
  end
48
73
 
49
- # Redefines all methods so that are forwarded to add_setup and be recorded
74
+ # Redefines all methods so that are forwarded to add_setup and be recorded.
75
+ # A read (see SETTING_READERS) is answered by the last instance, which is
76
+ # where add_setup takes its answer from, without being recorded.
50
77
  def override_all_methods!
51
78
  (base_instance.methods - Class.methods - NON_OVERRIDABLE).each do |method_override|
52
79
  define_singleton_method(method_override) do |*args, **kwargs, &block|
53
- add_setup(method: method_override, args:, kwargs:, block:)
80
+ step = { method: method_override, args:, kwargs:, block: }
81
+ return replay_step_on(@instances.last, **step) if setting_read?(method_override, args, kwargs, block)
82
+
83
+ add_setup(**step)
54
84
  end
55
85
  end
56
86
  end
@@ -136,6 +166,10 @@ module Grape
136
166
  evaluate_arguments(instance.configuration, response).first
137
167
  end
138
168
 
169
+ def setting_read?(method, args, kwargs, block)
170
+ SETTING_READERS.include?(method) && args.empty? && kwargs.empty? && block.nil?
171
+ end
172
+
139
173
  # Skips steps that contain arguments to be lazily executed (on re-mount time)
140
174
  def skip_immediate_run?(instance, args, kwargs)
141
175
  instance.base_instance? &&