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
@@ -13,12 +13,60 @@ module Grape
13
13
 
14
14
  ALL_MEDIA_TYPES = '*/*'
15
15
 
16
+ # The query param that names the format. See #format_from_query.
17
+ FORMAT_PARAM = 'format'
18
+
19
+ # @api private
20
+ # The format an Accept header asks for out of +mime_types+, or nil.
21
+ # Callers scrub the header first.
22
+ #
23
+ # Media types are case-insensitive (RFC 9110 §8.3.1) but the registered
24
+ # ones are spelled in lower case and Rack matches them literally, so an
25
+ # `Accept: TEXT/PLAIN` found nothing and fell through to the default
26
+ # format — the client quietly got something other than what it asked for.
27
+ def self.format_for_accept(accept_header, mime_types)
28
+ return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES
29
+
30
+ media_type = Rack::Utils.best_q_match(accept_header.downcase, mime_types.keys)
31
+ mime_types[media_type] if media_type
32
+ end
33
+
34
+ # +format_for_accept+ answers from the header and +mime_types+ alone. A
35
+ # client that names the format it wants sends one of those media types as
36
+ # it is registered (+application/json+), and most others send +*/*+ or
37
+ # nothing, so those answers are worked out once instead of re-running
38
+ # Rack's q-value match on every request. Any other header misses and is
39
+ # negotiated as before; only registered media types are keys, so a client
40
+ # cannot grow a table.
41
+ #
42
+ # Shared per +mime_types+, which Grape::ContentTypes already shares per
43
+ # content-type registry: every endpoint builds its own formatter.
44
+ class FormatForAcceptCache < Grape::Util::Cache
45
+ def initialize
46
+ super
47
+ @cache = Hash.new do |h, mime_types|
48
+ h[mime_types] = [*mime_types.keys, ALL_MEDIA_TYPES, nil].to_h { |accept| [accept, Formatter.format_for_accept(accept, mime_types)] }.freeze
49
+ end
50
+ end
51
+ end
52
+
16
53
  # 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.
54
+ # {#body_given?}, which tests the env against this before anything asks
55
+ # for a Rack::Request. QUERY is here because its content *is* the query
56
+ # (RFC 10008, Section 2), not an optional payload.
20
57
  BODY_CARRYING_METHODS = [Rack::POST, Rack::PUT, Rack::PATCH, Rack::DELETE, Grape::QUERY].freeze
21
58
 
59
+ # The media types whose bodies Rack parses itself once the params are
60
+ # asked for, so the formatter leaves them alone.
61
+ #
62
+ # Tested against the media type the formatter parses anyway, rather than
63
+ # through Rack's +form_data?+ and +parseable_data?+, which parse
64
+ # CONTENT_TYPE again each. It is the same answer: +form_data?+ also holds
65
+ # for a POST with no media type, but Rack only finds none when
66
+ # CONTENT_TYPE is missing or empty, and a body like that was never left
67
+ # to Rack.
68
+ RACK_PARSED_MEDIA_TYPES = (Rack::Request::FORM_DATA_MEDIA_TYPES + Rack::Request::PARSEABLE_DATA_MEDIA_TYPES).freeze
69
+
22
70
  # Read off ivars rather than delegated into +config+ on every request:
23
71
  # +negotiate_content_type+ asks for +format+ and +default_format+ per
24
72
  # request and +fetch_formatter+ for +formatters+, and each delegator cost
@@ -36,60 +84,115 @@ module Grape
36
84
  @formatters = config.formatters
37
85
  @parsers = config.parsers
38
86
  mime_types
87
+ # Only an API that pins no format negotiates one from the Accept header.
88
+ @format_for_accept = FormatForAcceptCache[mime_types] unless format
89
+ end
90
+
91
+ # Base#call copies the middleware per request so #before and #after can
92
+ # keep the env and the app's response in ivars. The formatter keeps
93
+ # neither: it passes the env along and answers from the one instance the
94
+ # stack built.
95
+ def call(env)
96
+ call!(env).to_a
39
97
  end
40
98
 
41
- def before
42
- negotiate_content_type
43
- read_body_input
99
+ def call!(env)
100
+ negotiated = negotiate_content_type(env)
101
+ read_body_input(env)
102
+ response = @app.call(env)
103
+ reporting_after_errors { format_response(env, response, negotiated) } || response
44
104
  end
45
105
 
46
- def after
47
- return unless @app_response
106
+ # Base's per-request readers look for the request in ivars that only a
107
+ # per-request copy sets, so the one instance the formatter answers from
108
+ # has none to give: +env+ is nil, +response+ raises, and +rack_request+
109
+ # would memoize a request on the instance every request shares.
110
+ private :env, :context, :rack_request, :query_params, :response
111
+
112
+ private
113
+
114
+ def format_response(env, response, negotiated)
115
+ return unless response
48
116
 
49
- status, headers, bodies = @app_response
117
+ status, headers, bodies = response
50
118
 
51
119
  return [status, headers, []] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status)
52
120
 
53
- build_formatted_response(status, headers, bodies)
121
+ build_formatted_response(env, status, headers, bodies, negotiated)
54
122
  end
55
123
 
56
- private
124
+ def build_formatted_response(env, status, headers, bodies, negotiated)
125
+ content_type_given = headers.key?(Rack::CONTENT_TYPE)
126
+ ensure_content_type!(env, headers)
127
+ return stream_response(status, headers, bodies) if bodies.is_a?(Grape::ServeStream::StreamResponse)
128
+
129
+ bodymap = format_bodies(env, headers, bodies)
130
+ # A formatter that renders a body only as Rack reads it (see
131
+ # Grape::Formatter::Jsonl) hands back a stream, served as +stream+'s is.
132
+ return stream_response(status, headers, bodymap.first) if bodymap.first.is_a?(Grape::ServeStream::StreamResponse)
133
+
134
+ # A bare Rack tuple rather than a Rack::Response: +headers+ is already
135
+ # a Grape::Util::Header (a Rack::Headers on Rack 3), so wrapping only
136
+ # re-normalizes the same keys into a second Headers hash that
137
+ # +Middleware::Base#call+ unwraps again with +to_a+ on the way out.
138
+ # The 204/304 bodies Rack::Response#finish would blank are returned
139
+ # by #format_response, before this point.
140
+ [status, headers, bodymap]
141
+ rescue Grape::Exceptions::InvalidFormatter => e
142
+ fallback = format_as_default(env, status, headers, bodies) if falls_back?(env, negotiated, content_type_given)
143
+ return fallback if fallback
57
144
 
58
- def build_formatted_response(status, headers, bodies)
59
- ensure_content_type!(headers)
145
+ throw :error, Grape::Exceptions::ErrorResponse.new(status: 500, message: e.message, backtrace: e.backtrace, original_exception: e)
146
+ end
60
147
 
61
- if bodies.is_a?(Grape::ServeStream::StreamResponse)
62
- Grape::ServeStream::SendfileResponse.new([], status, headers) do |resp|
63
- resp.body = bodies.stream
64
- end
65
- else
66
- # Allow content-type to be explicitly overwritten
67
- formatter = fetch_formatter(headers)
68
- bodymap = instrument_format_response(formatter) do
69
- bodies.map { |body| formatter.call(body, env) }
70
- end
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]
148
+ def format_bodies(env, headers, bodies)
149
+ # Picked by env['api.format']; the Content-Type header only counts when that is unset
150
+ formatter = fetch_formatter(env, headers)
151
+ instrument_format_response(env, formatter) do
152
+ bodies.map { |body| formatter.call(body, env) }
78
153
  end
79
- rescue Grape::Exceptions::InvalidFormatter => e
80
- throw :error, Grape::Exceptions::ErrorResponse.new(status: 500, message: e.message, backtrace: e.backtrace, original_exception: e)
154
+ end
155
+
156
+ def stream_response(status, headers, bodies)
157
+ Grape::ServeStream::SendfileResponse.new([], status, headers) do |resp|
158
+ resp.body = bodies.stream
159
+ end
160
+ end
161
+
162
+ # A format the client negotiated -- by its Accept header, the path's
163
+ # extension or +?format=+ -- that cannot render the body answers in the
164
+ # default format rather than 500. On an API that pins no format, a
165
+ # browser's Accept header and axios's both negotiate XML, which cannot
166
+ # render a String. The fallback applies only while the format is the
167
+ # client's alone: when the API pins one, or the endpoint set a
168
+ # Content-Type or another format itself, a body that does not fit is the
169
+ # endpoint's error and still answers 500.
170
+ def falls_back?(env, negotiated, content_type_given)
171
+ format.nil? && !content_type_given && env[Grape::Env::API_FORMAT] == negotiated
172
+ end
173
+
174
+ # Nil when the default format cannot render the body either.
175
+ def format_as_default(env, status, headers, bodies)
176
+ formatter = Grape::Formatter.formatter_for(default_format, formatters)
177
+ bodymap = bodies.map { |body| formatter.call(body, env) }
178
+ env[Grape::Env::API_FORMAT] = default_format.to_sym
179
+ headers.delete(Rack::CONTENT_TYPE)
180
+ ensure_content_type!(env, headers)
181
+ [status, headers, bodymap]
182
+ rescue Grape::Exceptions::InvalidFormatter
183
+ nil
81
184
  end
82
185
 
83
186
  # Guards on +listening?+ so that with no subscriber the payload Hash and
84
187
  # notification machinery are skipped and the block runs directly (no added
85
188
  # allocations); the block is forwarded anonymously.
86
- def instrument_format_response(formatter, &)
189
+ def instrument_format_response(env, formatter, &)
87
190
  return yield unless ActiveSupport::Notifications.notifier.listening?('format_response.grape')
88
191
 
89
192
  ActiveSupport::Notifications.instrument('format_response.grape', formatter:, env:, &)
90
193
  end
91
194
 
92
- def fetch_formatter(headers)
195
+ def fetch_formatter(env, headers)
93
196
  api_format = env.fetch(Grape::Env::API_FORMAT) { mime_types[headers[Rack::CONTENT_TYPE]] }
94
197
  Grape::Formatter.formatter_for(api_format, formatters)
95
198
  end
@@ -100,18 +203,26 @@ module Grape
100
203
  # this runs on every response, and copying the hash costs an allocation
101
204
  # per request for a header the caller is about to send anyway.
102
205
  #
206
+ # A format with no media type at all (see #media_type_for) leaves the
207
+ # header out: Rack forbids a nil header value.
208
+ #
103
209
  # @param headers [Hash] the response headers, mutated in place
104
210
  # @return [void]
105
- def ensure_content_type!(headers)
211
+ def ensure_content_type!(env, headers)
106
212
  return if headers[Rack::CONTENT_TYPE]
107
213
 
108
- headers[Rack::CONTENT_TYPE] = content_type_for(env[Grape::Env::API_FORMAT])
214
+ content_type = media_type_for(env[Grape::Env::API_FORMAT])
215
+ headers[Rack::CONTENT_TYPE] = content_type if content_type
109
216
  end
110
217
 
111
- def read_body_input
112
- return unless read_body_input?
218
+ def read_body_input(env)
219
+ return unless body_given?(env)
220
+
221
+ request = Rack::Request.new(env)
222
+ media_type = request.media_type
223
+ return if RACK_PARSED_MEDIA_TYPES.include?(media_type)
113
224
 
114
- input = rack_request.body # reads RACK_INPUT
225
+ input = request.body # reads RACK_INPUT
115
226
  return if input.nil?
116
227
 
117
228
  rewind = input.respond_to?(:rewind)
@@ -119,17 +230,40 @@ module Grape
119
230
  input.rewind if rewind
120
231
  body = env[Grape::Env::API_REQUEST_INPUT] = input.read
121
232
  begin
122
- read_rack_input(body)
233
+ read_rack_input(env, body, media_type)
123
234
  ensure
124
235
  input.rewind if rewind
125
236
  end
126
237
  end
127
238
 
128
- def read_rack_input(body)
239
+ def read_rack_input(env, body, media_type)
129
240
  return if body.empty?
130
241
 
131
- media_type = rack_request.media_type
242
+ parser = Grape::Parser.parser_for body_format(env, media_type), parsers
243
+ return env[Grape::Env::API_REQUEST_BODY] = body unless parser
132
244
 
245
+ begin
246
+ parsed = (env[Grape::Env::API_REQUEST_BODY] = parser.call(body, env))
247
+ merge_form_hash(env, parsed) if parsed.is_a?(Hash)
248
+ rescue ForeignParserError => e
249
+ throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: e.message, backtrace: e.backtrace, original_exception: e)
250
+ end
251
+ end
252
+
253
+ # A body parsed into a Hash joins the form params Rack::Request reads,
254
+ # as a form body Rack parsed itself would.
255
+ def merge_form_hash(env, parsed)
256
+ if (form_hash = env[Rack::RACK_REQUEST_FORM_HASH])
257
+ form_hash.merge!(parsed)
258
+ else
259
+ env[Rack::RACK_REQUEST_FORM_HASH] = parsed
260
+ end
261
+ env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT]
262
+ end
263
+
264
+ # The format a body of +media_type+ is read as, thrown as an error
265
+ # response when it cannot be read at all.
266
+ def body_format(env, media_type)
133
267
  # RFC 10008, Sections 2 and 2.1: a QUERY carries its query in the
134
268
  # content, so a request that never says what that content is cannot be
135
269
  # interpreted and must fail rather than be read as the default format.
@@ -137,50 +271,41 @@ module Grape
137
271
  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
272
 
139
273
  fmt = media_type ? mime_types[media_type] : default_format
140
-
141
274
  throw :error, Grape::Exceptions::ErrorResponse.new(status: 415, message: "The provided content-type '#{media_type}' is not supported.") unless content_type_for(fmt)
142
- parser = Grape::Parser.parser_for fmt, parsers
143
- return env[Grape::Env::API_REQUEST_BODY] = body unless parser
144
275
 
145
- begin
146
- parsed = (env[Grape::Env::API_REQUEST_BODY] = parser.call(body, env))
147
- if parsed.is_a?(Hash)
148
- if (form_hash = env[Rack::RACK_REQUEST_FORM_HASH])
149
- form_hash.merge!(parsed)
150
- else
151
- env[Rack::RACK_REQUEST_FORM_HASH] = parsed
152
- end
153
- env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT]
154
- end
155
- rescue Grape::Exceptions::Base => e
156
- raise e
157
- rescue StandardError => e
158
- throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: e.message, backtrace: e.backtrace, original_exception: e)
159
- end
276
+ fmt
160
277
  end
161
278
 
162
- # this middleware will not try to format the following content-types since Rack already handles them
163
- # when calling Rack's `params` function
164
- # - application/x-www-form-urlencoded
165
- # - multipart/form-data
166
- # - multipart/related
167
- # - multipart/mixed
168
- def read_body_input?
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.
279
+ # What a parser raises that is not a Grape error, and so is answered as a
280
+ # 400 here. A Grape error goes on to the error middleware as it is. It
281
+ # used to be rescued just to be raised again, and re-raising at request
282
+ # depth cost about 25 µs -- paid by every malformed body, since the
283
+ # built-in parsers report one as InvalidMessageBody.
284
+ module ForeignParserError
285
+ def self.===(exception)
286
+ exception.is_a?(StandardError) && !exception.is_a?(Grape::Exceptions::Base)
287
+ end
288
+ end
289
+ private_constant :ForeignParserError
290
+
291
+ # Whether the request carries a body at all, read off the env alone. Every
292
+ # request asks, and the ones answered no -- the GET, HEAD and OPTIONS
293
+ # majority, and a POST or DELETE with nothing in it -- are answered before
294
+ # CONTENT_TYPE is parsed or a Rack::Request is built. The env key is
295
+ # spelled out: on Rack 3, +Rack::CONTENT_LENGTH+ names the response header.
296
+ def body_given?(env)
173
297
  return false unless BODY_CARRYING_METHODS.include?(env[Rack::REQUEST_METHOD])
174
- return false if rack_request.form_data? && rack_request.content_type
175
- return false if rack_request.parseable_data?
176
298
 
177
- rack_request.content_length.to_i.positive? || env['HTTP_TRANSFER_ENCODING'] == 'chunked'
299
+ env['CONTENT_LENGTH'].to_i.positive? || env['HTTP_TRANSFER_ENCODING'] == 'chunked'
178
300
  end
179
301
 
180
- def negotiate_content_type
181
- fmt = format_from_extension || format_from_query || format || format_from_header || default_format
302
+ def negotiate_content_type(env)
303
+ fmt = format_from_extension(env) || format_from_query(env) || format || format_from_header(env) || default_format
182
304
  return env[Grape::Env::API_FORMAT] = fmt.to_sym if content_type_for(fmt)
183
305
 
306
+ synonym = declared_synonym(fmt)
307
+ return env[Grape::Env::API_FORMAT] = synonym if synonym
308
+
184
309
  throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, message: "The requested format '#{fmt}' is not supported.")
185
310
  end
186
311
 
@@ -189,13 +314,24 @@ module Grape
189
314
  # sequence, and a +.+ byte cannot be part of a multi-byte one, so the dot
190
315
  # sits at the same place before and after scrubbing. The overwhelming
191
316
  # majority of paths carry no extension and now skip the scrub entirely.
192
- def format_from_extension
193
- request_path = path_for_extension
317
+ def format_from_extension(env)
318
+ request_path = path_for_extension(env)
194
319
  dot_pos = request_path.rindex('.')
195
320
  return unless dot_pos
196
321
 
197
- extension = try_scrub(request_path[(dot_pos + 1)..])
198
- extension if content_type_for(extension)
322
+ # A start and a length rather than an endless Range, which is an object
323
+ # of its own built on every call. A length past the end is clamped.
324
+ extension = try_scrub(request_path[dot_pos + 1, request_path.length])
325
+ return extension if content_type_for(extension)
326
+
327
+ declared_synonym(extension)
328
+ end
329
+
330
+ # The name the API declared +fmt+ under when the request used another
331
+ # one for it (see Grape::ContentTypes::FORMAT_SYNONYMS), or nil.
332
+ def declared_synonym(fmt)
333
+ synonym = Grape::ContentTypes::FORMAT_SYNONYMS[fmt]
334
+ synonym if synonym && content_type_for(synonym)
199
335
  end
200
336
 
201
337
  # The extension is the tail of the request path, so PATH_INFO answers it
@@ -205,32 +341,37 @@ module Grape
205
341
  # the String its concatenation allocates. Tested with +empty?+ rather
206
342
  # than +blank?+: the path is not scrubbed yet, and a regexp match on an
207
343
  # invalid byte sequence raises.
208
- def path_for_extension
344
+ def path_for_extension(env)
209
345
  path_info = env[Rack::PATH_INFO]
210
- return rack_request.path if path_info.nil? || path_info.empty?
346
+ return Rack::Request.new(env).path if path_info.nil? || path_info.empty?
211
347
 
212
348
  path_info
213
349
  end
214
350
 
215
351
  # +?format=+ can only be there when there is a query string at all, so
216
352
  # the common query-less request skips parsing one.
217
- def format_from_query
353
+ #
354
+ # And a query string that spells neither +format+ nor a percent-escape
355
+ # cannot hold that key either: Rack splits the string on +&+ and +=+ and
356
+ # then unescapes each key, and unescaping only ever rewrites +%XX+ (or a
357
+ # +++, which becomes a space). So the parse -- the most expensive thing
358
+ # a request with a query string does, and one the endpoint may never ask
359
+ # for -- is left to whoever reads the params.
360
+ def format_from_query(env)
218
361
  query_string = env[Rack::QUERY_STRING]
219
362
  return if query_string.nil? || query_string.empty?
363
+ return unless query_string.include?(FORMAT_PARAM) || query_string.include?('%')
220
364
 
221
- query_params['format']
365
+ Rack::Request.new(env).GET[FORMAT_PARAM]
366
+ rescue *Grape::RACK_ERRORS
367
+ raise Grape::Exceptions::RequestError
222
368
  end
223
369
 
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.
228
- def format_from_header
229
- accept_header = try_scrub(env['HTTP_ACCEPT'])
230
- return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES
231
-
232
- media_type = Rack::Utils.best_q_match(accept_header.downcase, mime_types.keys)
233
- mime_types[media_type] if media_type
370
+ # The keys are registered media types -- valid strings, which scrubbing
371
+ # leaves alone -- so only a miss needs the header scrubbed.
372
+ def format_from_header(env)
373
+ accept_header = env['HTTP_ACCEPT']
374
+ @format_for_accept.fetch(accept_header) { Formatter.format_for_accept(try_scrub(accept_header), mime_types) }
234
375
  end
235
376
  end
236
377
  end
@@ -4,7 +4,7 @@ module Grape
4
4
  module Middleware
5
5
  # Include in a middleware subclass that needs content-type negotiation.
6
6
  # Provides +content_types+ / +mime_types+ / +content_type_for+ /
7
- # +content_type+ resolved from +config.content_types+ and
7
+ # +media_type_for+ / +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
10
  # at initialization so per-request +dup+s inherit them rather than
@@ -19,6 +19,9 @@ module Grape
19
19
  # Opt-in: plain +Grape::Middleware::Base+ subclasses that don't need
20
20
  # content-type-aware helpers don't pay for them.
21
21
  module PrecomputedContentTypes
22
+ # Grape's own media type for each built-in format, under both spellings.
23
+ DEFAULT_CONTENT_TYPES = Grape::ContentTypes.lookup_for(Grape::ContentTypes::DEFAULTS)
24
+
22
25
  def initialize(app, **options)
23
26
  super
24
27
  content_types
@@ -37,8 +40,17 @@ module Grape
37
40
  content_types_lookup[format]
38
41
  end
39
42
 
43
+ # The media type to label a response rendered in +format+ with: the one
44
+ # this API declares for it, or else Grape's own -- +api_format :txt+ in
45
+ # a +format :json+ API renders text/plain. #content_type_for stays nil
46
+ # for a format the API does not declare, since negotiation asks it
47
+ # whether the API supports the format at all.
48
+ def media_type_for(format)
49
+ content_type_for(format) || DEFAULT_CONTENT_TYPES[format]
50
+ end
51
+
40
52
  def content_type
41
- content_type_for(env[Grape::Env::API_FORMAT] || config.format) || 'text/html'
53
+ media_type_for(env[Grape::Env::API_FORMAT] || config.format) || 'text/html'
42
54
  end
43
55
 
44
56
  private
@@ -80,14 +80,13 @@ module Grape
80
80
  middlewares.push(middleware)
81
81
  end
82
82
 
83
+ # Each spec ends in the block its operation was given, +nil+ when there
84
+ # was none. Telling the block apart by its class would take a Proc passed
85
+ # as the last argument, such as a callable the middleware is configured
86
+ # with, for the block.
83
87
  def merge_with(middleware_specs)
84
- middleware_specs.each do |operation, klass, *args|
85
- if args.last.is_a?(Proc)
86
- last_proc = args.pop
87
- public_send(operation, klass, *args, &last_proc)
88
- else
89
- public_send(operation, klass, *args)
90
- end
88
+ middleware_specs.each do |operation, *args, block|
89
+ public_send(operation, *args, &block)
91
90
  end
92
91
  end
93
92
 
@@ -100,7 +99,7 @@ module Grape
100
99
  end
101
100
 
102
101
  # @description Add middlewares with :use operation to the stack. Store others with :insert_* operation for later
103
- # @param [Array] other_specs An array of middleware specifications (e.g. [[:use, klass], [:insert_before, *args]])
102
+ # @param [Array] other_specs An array of middleware specifications (e.g. [[:use, klass, nil], [:insert_before, *args, block]])
104
103
  def concat(other_specs)
105
104
  use, not_use = other_specs.partition { |o| o.first == :use }
106
105
  others << not_use
@@ -27,18 +27,30 @@ module Grape
27
27
 
28
28
  attr_reader :available_media_types, :error_headers, :versions
29
29
 
30
+ # Read off ivars rather than delegated through +version_options+ into
31
+ # +config+: the versioners ask for +vendor+, +strict+ or +parameter+ on
32
+ # every request, and each read went two Forwardable frames and two Data
33
+ # readers deep for a value fixed when the middleware was built.
34
+ attr_reader :cascade, :parameter, :strict, :vendor
35
+
30
36
  def_delegators :config, :mount_path, :prefix, :version_options
31
- def_delegators :version_options, :cascade, :parameter, :strict, :vendor
32
37
 
33
38
  def initialize(app, **options)
34
39
  super
40
+ @cascade = version_options.cascade
41
+ @parameter = version_options.parameter
42
+ @strict = version_options.strict
43
+ # Media types are case-insensitive, and Grape::Util::MediaType parses
44
+ # an Accept header into lowercase; a vendor declared with capitals
45
+ # would otherwise never match one.
46
+ @vendor = version_options.vendor&.downcase
35
47
  @versions = config.versions&.map(&:to_s) # making sure versions are strings to ease potential match
36
48
  @error_headers = cascade ? CASCADE_PASS_HEADER : {}
37
49
  @available_media_types = build_available_media_types
38
50
  end
39
51
 
40
52
  def potential_version_match?(potential_version)
41
- versions.blank? || versions.include?(potential_version)
53
+ versions.nil? || versions.empty? || versions.include?(potential_version)
42
54
  end
43
55
 
44
56
  def version_not_found!
@@ -50,15 +62,19 @@ module Grape
50
62
  def build_available_media_types
51
63
  media_types = []
52
64
  base_media_type = "application/vnd.#{vendor}"
53
- versions&.reverse_each do |version|
54
- versioned_base = "#{base_media_type}-#{version}"
55
- content_types.each_key { |extension| media_types << "#{versioned_base}+#{extension}" }
56
- media_types << versioned_base
57
- end
58
- content_types.each_key { |extension| media_types << "#{base_media_type}+#{extension}" }
59
- media_types << base_media_type
65
+ versions&.reverse_each { |version| append_media_types(media_types, "#{base_media_type}-#{version}") }
66
+ append_media_types(media_types, base_media_type)
60
67
  media_types.concat(content_types.values.flatten)
61
- media_types
68
+ # Media types are case-insensitive, and Grape::Util::MediaType matches
69
+ # an Accept header in lowercase; a version or content type declared
70
+ # with capitals would otherwise never match one.
71
+ media_types.map!(&:downcase)
72
+ end
73
+
74
+ # +base+ with each content type's extension, then +base+ alone.
75
+ def append_media_types(media_types, base)
76
+ content_types.each_key { |extension| media_types << "#{base}+#{extension}" }
77
+ media_types << base
62
78
  end
63
79
  end
64
80
  end