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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -0
- data/CONTRIBUTING.md +16 -0
- data/README.md +208 -8
- data/UPGRADING.md +756 -6
- data/lib/grape/api/instance.rb +40 -33
- data/lib/grape/content_types.rb +47 -1
- data/lib/grape/dry_types.rb +1 -1
- data/lib/grape/dsl/callbacks.rb +3 -9
- data/lib/grape/dsl/declared.rb +4 -4
- data/lib/grape/dsl/desc.rb +18 -8
- data/lib/grape/dsl/entity.rb +37 -19
- data/lib/grape/dsl/helpers.rb +3 -3
- data/lib/grape/dsl/inside_route.rb +20 -8
- data/lib/grape/dsl/middleware.rb +3 -3
- data/lib/grape/dsl/parameters.rb +54 -28
- data/lib/grape/dsl/request_response.rb +37 -39
- data/lib/grape/dsl/rescue_options.rb +6 -5
- data/lib/grape/dsl/routing.rb +116 -49
- data/lib/grape/dsl/settings.rb +1 -1
- data/lib/grape/dsl/validations.rb +3 -3
- data/lib/grape/dsl/version_options.rb +3 -3
- data/lib/grape/endpoint/options.rb +13 -7
- data/lib/grape/endpoint.rb +141 -118
- data/lib/grape/env.rb +7 -0
- data/lib/grape/error_formatter/base.rb +6 -5
- data/lib/grape/error_formatter.rb +6 -2
- data/lib/grape/exceptions/error_response.rb +4 -1
- data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
- data/lib/grape/exceptions/validation.rb +9 -8
- data/lib/grape/formatter/json.rb +1 -0
- data/lib/grape/formatter/serializable_hash.rb +1 -0
- data/lib/grape/locale/en.yml +1 -0
- data/lib/grape/middleware/auth/dsl.rb +5 -33
- data/lib/grape/middleware/error.rb +153 -46
- data/lib/grape/middleware/formatter.rb +95 -26
- data/lib/grape/middleware/precomputed_content_types.rb +12 -7
- data/lib/grape/middleware/stack.rb +13 -4
- data/lib/grape/middleware/versioner/base.rb +3 -9
- data/lib/grape/middleware/versioner/path.rb +49 -7
- data/lib/grape/namespace.rb +3 -2
- data/lib/grape/path.rb +8 -69
- data/lib/grape/precompiled_json.rb +50 -0
- data/lib/grape/request.rb +36 -4
- data/lib/grape/router/base_route.rb +47 -10
- data/lib/grape/router/greedy_route.rb +5 -1
- data/lib/grape/router/pattern/path.rb +78 -0
- data/lib/grape/router/pattern.rb +60 -14
- data/lib/grape/router/route.rb +62 -10
- data/lib/grape/router.rb +100 -57
- data/lib/grape/serve_stream/file_body.rb +6 -0
- data/lib/grape/serve_stream/stream_response.rb +6 -0
- data/lib/grape/util/api_description.rb +10 -1
- data/lib/grape/util/cache.rb +21 -2
- data/lib/grape/util/deep_freeze.rb +1 -2
- data/lib/grape/util/freeze_on_new.rb +20 -0
- data/lib/grape/util/inheritable_setting.rb +751 -37
- data/lib/grape/util/media_type.rb +10 -3
- data/lib/grape/util/path_normalizer.rb +12 -9
- data/lib/grape/util/registry.rb +12 -2
- data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
- data/lib/grape/util/stackable_values.rb +32 -6
- data/lib/grape/validations/attributes_iterator.rb +21 -4
- data/lib/grape/validations/contract_scope.rb +8 -7
- data/lib/grape/validations/oneof_collector.rb +6 -10
- data/lib/grape/validations/params_documentation.rb +2 -2
- data/lib/grape/validations/params_scope.rb +66 -26
- data/lib/grape/validations/single_attribute_iterator.rb +5 -3
- data/lib/grape/validations/types/array_coercer.rb +4 -6
- data/lib/grape/validations/types/custom_type_coercer.rb +7 -1
- data/lib/grape/validations/types/dry_type_coercer.rb +3 -1
- data/lib/grape/validations/types/json.rb +1 -3
- data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
- data/lib/grape/validations/types/primitive_coercer.rb +12 -9
- data/lib/grape/validations/types/variant_collection_coercer.rb +8 -3
- data/lib/grape/validations/types.rb +5 -5
- data/lib/grape/validations/validations_spec.rb +14 -4
- data/lib/grape/validations/validators/base.rb +4 -7
- data/lib/grape/validations/validators/coerce_validator.rb +8 -2
- data/lib/grape/validations/validators/contract_scope_validator.rb +2 -1
- data/lib/grape/validations/validators/length_validator.rb +4 -2
- data/lib/grape/validations/validators/oneof_validator.rb +2 -0
- data/lib/grape/validations/validators/same_as_validator.rb +1 -0
- data/lib/grape/version.rb +2 -1
- data/lib/grape.rb +23 -2
- metadata +10 -9
- data/lib/grape/middleware/deprecated_options_hash_access.rb +0 -19
- data/lib/grape/util/base_inheritable.rb +0 -43
- data/lib/grape/util/inheritable_values.rb +0 -33
- 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:`
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
rescue_options
|
|
31
|
-
|
|
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
|
-
|
|
62
|
-
Rack::Response.new(Array.wrap(
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
rescue_handler_from(rescue_handlers) { |err| klass <= err }
|
|
206
|
+
registered_rescue_handler_entry(klass)&.last
|
|
123
207
|
end
|
|
124
208
|
|
|
125
|
-
|
|
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
|
-
|
|
269
|
+
callable = handler.is_a?(Symbol) ? endpoint.public_method(handler) : handler
|
|
150
270
|
response = catch(:error) do
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
114
|
-
if
|
|
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!(
|
|
149
|
+
form_hash.merge!(parsed)
|
|
117
150
|
else
|
|
118
|
-
env[Rack::RACK_REQUEST_FORM_HASH] =
|
|
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
|
-
|
|
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? ||
|
|
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 ||
|
|
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 =
|
|
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
|
|
11
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
@
|
|
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
|
-
|
|
59
|
-
middlewares.insert(
|
|
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
|
-
|
|
66
|
-
insert(
|
|
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, :
|
|
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,
|
|
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, :
|
|
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)
|