grape 2.4.0 → 4.0.1

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +351 -0
  3. data/CONTRIBUTING.md +18 -10
  4. data/README.md +457 -228
  5. data/UPGRADING.md +1240 -1
  6. data/grape.gemspec +7 -7
  7. data/lib/grape/api/instance.rb +65 -116
  8. data/lib/grape/api.rb +53 -62
  9. data/lib/grape/content_types.rb +58 -3
  10. data/lib/grape/cookies.rb +2 -6
  11. data/lib/grape/declared_params_handler.rb +116 -0
  12. data/lib/grape/dry_types.rb +48 -4
  13. data/lib/grape/dsl/callbacks.rb +8 -58
  14. data/lib/grape/dsl/declared.rb +35 -0
  15. data/lib/grape/dsl/desc.rb +24 -67
  16. data/lib/grape/dsl/entity.rb +106 -0
  17. data/lib/grape/dsl/helpers.rb +80 -65
  18. data/lib/grape/dsl/inside_route.rb +56 -296
  19. data/lib/grape/dsl/logger.rb +4 -9
  20. data/lib/grape/dsl/middleware.rb +22 -40
  21. data/lib/grape/dsl/parameters.rb +86 -93
  22. data/lib/grape/dsl/request_response.rb +139 -139
  23. data/lib/grape/dsl/rescue_options.rb +25 -0
  24. data/lib/grape/dsl/routing.rb +328 -203
  25. data/lib/grape/dsl/settings.rb +29 -135
  26. data/lib/grape/dsl/validations.rb +38 -44
  27. data/lib/grape/dsl/version_options.rb +23 -0
  28. data/lib/grape/endpoint/options.rb +25 -0
  29. data/lib/grape/endpoint.rb +285 -245
  30. data/lib/grape/env.rb +8 -3
  31. data/lib/grape/error_formatter/base.rb +27 -21
  32. data/lib/grape/error_formatter/json.rb +6 -0
  33. data/lib/grape/error_formatter/txt.rb +10 -10
  34. data/lib/grape/error_formatter.rb +6 -2
  35. data/lib/grape/exceptions/base.rb +20 -44
  36. data/lib/grape/exceptions/error_response.rb +48 -0
  37. data/lib/grape/exceptions/incompatible_option_values.rb +1 -1
  38. data/lib/grape/exceptions/internal_server_error.rb +16 -0
  39. data/lib/grape/exceptions/invalid_accept_header.rb +1 -1
  40. data/lib/grape/exceptions/invalid_formatter.rb +1 -1
  41. data/lib/grape/exceptions/invalid_message_body.rb +1 -1
  42. data/lib/grape/exceptions/invalid_version_header.rb +1 -1
  43. data/lib/grape/exceptions/invalid_versioner_option.rb +1 -1
  44. data/lib/grape/exceptions/method_not_allowed.rb +1 -1
  45. data/lib/grape/exceptions/missing_group_type.rb +0 -2
  46. data/lib/grape/exceptions/missing_mime_type.rb +1 -1
  47. data/lib/grape/exceptions/request_error.rb +11 -0
  48. data/lib/grape/exceptions/unknown_auth_strategy.rb +1 -1
  49. data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
  50. data/lib/grape/exceptions/unknown_parameter.rb +1 -1
  51. data/lib/grape/exceptions/unknown_params_builder.rb +1 -1
  52. data/lib/grape/exceptions/unknown_validator.rb +1 -1
  53. data/lib/grape/exceptions/unsupported_group_type.rb +0 -2
  54. data/lib/grape/exceptions/validation.rb +25 -7
  55. data/lib/grape/exceptions/validation_array_errors.rb +4 -0
  56. data/lib/grape/exceptions/validation_errors.rb +21 -23
  57. data/lib/grape/formatter/json.rb +1 -0
  58. data/lib/grape/formatter/serializable_hash.rb +6 -9
  59. data/lib/grape/json.rb +38 -2
  60. data/lib/grape/locale/en.yml +3 -5
  61. data/lib/grape/middleware/auth/base.rb +4 -3
  62. data/lib/grape/middleware/auth/dsl.rb +9 -23
  63. data/lib/grape/middleware/base.rb +41 -36
  64. data/lib/grape/middleware/error.rb +271 -73
  65. data/lib/grape/middleware/formatter.rb +152 -63
  66. data/lib/grape/middleware/precomputed_content_types.rb +51 -0
  67. data/lib/grape/middleware/stack.rb +20 -11
  68. data/lib/grape/middleware/versioner/accept_version_header.rb +3 -3
  69. data/lib/grape/middleware/versioner/base.rb +40 -48
  70. data/lib/grape/middleware/versioner/header.rb +5 -23
  71. data/lib/grape/middleware/versioner/param.rb +2 -2
  72. data/lib/grape/middleware/versioner/path.rb +54 -7
  73. data/lib/grape/mountable.rb +22 -0
  74. data/lib/grape/namespace.rb +19 -11
  75. data/lib/grape/params_builder/base.rb +2 -0
  76. data/lib/grape/params_builder/hash_with_indifferent_access.rb +1 -1
  77. data/lib/grape/params_builder.rb +2 -19
  78. data/lib/grape/parser/json.rb +1 -1
  79. data/lib/grape/path.rb +8 -72
  80. data/lib/grape/precompiled_json.rb +50 -0
  81. data/lib/grape/request.rb +57 -20
  82. data/lib/grape/router/base_route.rb +53 -7
  83. data/lib/grape/router/greedy_route.rb +16 -6
  84. data/lib/grape/router/mustermann_pattern.rb +44 -0
  85. data/lib/grape/router/pattern/path.rb +78 -0
  86. data/lib/grape/router/pattern.rb +65 -37
  87. data/lib/grape/router/route.rb +66 -18
  88. data/lib/grape/router.rb +126 -115
  89. data/lib/grape/serve_stream/file_body.rb +7 -0
  90. data/lib/grape/serve_stream/sendfile_response.rb +3 -5
  91. data/lib/grape/serve_stream/stream_response.rb +7 -0
  92. data/lib/grape/testing.rb +33 -0
  93. data/lib/grape/util/api_description.rb +67 -0
  94. data/lib/grape/util/cache.rb +22 -2
  95. data/lib/grape/util/deep_freeze.rb +34 -0
  96. data/lib/grape/util/freeze_on_new.rb +20 -0
  97. data/lib/grape/util/inheritable_setting.rb +777 -39
  98. data/lib/grape/util/lazy/base.rb +16 -0
  99. data/lib/grape/util/lazy/block.rb +2 -9
  100. data/lib/grape/util/lazy/value.rb +2 -9
  101. data/lib/grape/util/lazy/value_enumerable.rb +13 -16
  102. data/lib/grape/util/media_type.rb +13 -9
  103. data/lib/grape/util/path_normalizer.rb +37 -0
  104. data/lib/grape/util/registry.rb +13 -3
  105. data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
  106. data/lib/grape/util/stackable_values.rb +39 -10
  107. data/lib/grape/util/translation.rb +42 -0
  108. data/lib/grape/validations/attributes_iterator.rb +61 -29
  109. data/lib/grape/validations/coerce_options.rb +21 -0
  110. data/lib/grape/validations/contract_scope.rb +8 -13
  111. data/lib/grape/validations/multiple_attributes_iterator.rb +1 -1
  112. data/lib/grape/validations/oneof_collector.rb +35 -0
  113. data/lib/grape/validations/param_scope_tracker.rb +62 -0
  114. data/lib/grape/validations/params_documentation.rb +52 -0
  115. data/lib/grape/validations/params_scope.rb +217 -304
  116. data/lib/grape/validations/shared_options.rb +19 -0
  117. data/lib/grape/validations/single_attribute_iterator.rb +6 -4
  118. data/lib/grape/validations/types/array_coercer.rb +7 -10
  119. data/lib/grape/validations/types/custom_type_coercer.rb +47 -85
  120. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  121. data/lib/grape/validations/types/dry_type_coercer.rb +10 -15
  122. data/lib/grape/validations/types/json.rb +1 -3
  123. data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
  124. data/lib/grape/validations/types/primitive_coercer.rb +14 -33
  125. data/lib/grape/validations/types/set_coercer.rb +1 -1
  126. data/lib/grape/validations/types/variant_collection_coercer.rb +16 -3
  127. data/lib/grape/validations/types.rb +29 -51
  128. data/lib/grape/validations/validations_spec.rb +164 -0
  129. data/lib/grape/validations/validators/all_or_none_of_validator.rb +6 -3
  130. data/lib/grape/validations/validators/allow_blank_validator.rb +10 -5
  131. data/lib/grape/validations/validators/at_least_one_of_validator.rb +5 -2
  132. data/lib/grape/validations/validators/base.rb +118 -34
  133. data/lib/grape/validations/validators/coerce_validator.rb +25 -37
  134. data/lib/grape/validations/validators/contract_scope_validator.rb +10 -5
  135. data/lib/grape/validations/validators/default_validator.rb +13 -20
  136. data/lib/grape/validations/validators/exactly_one_of_validator.rb +10 -3
  137. data/lib/grape/validations/validators/except_values_validator.rb +14 -4
  138. data/lib/grape/validations/validators/length_validator.rb +23 -22
  139. data/lib/grape/validations/validators/multiple_params_base.rb +12 -9
  140. data/lib/grape/validations/validators/{mutual_exclusion_validator.rb → mutually_exclusive_validator.rb} +4 -2
  141. data/lib/grape/validations/validators/oneof_validator.rb +51 -0
  142. data/lib/grape/validations/validators/presence_validator.rb +4 -2
  143. data/lib/grape/validations/validators/regexp_validator.rb +11 -3
  144. data/lib/grape/validations/validators/same_as_validator.rb +7 -15
  145. data/lib/grape/validations/validators/values_validator.rb +34 -26
  146. data/lib/grape/version.rb +1 -2
  147. data/lib/grape/xml.rb +8 -1
  148. data/lib/grape.rb +58 -31
  149. metadata +62 -39
  150. data/lib/grape/api/helpers.rb +0 -9
  151. data/lib/grape/dsl/api.rb +0 -17
  152. data/lib/grape/dsl/configuration.rb +0 -15
  153. data/lib/grape/exceptions/conflicting_types.rb +0 -11
  154. data/lib/grape/exceptions/empty_message_body.rb +0 -11
  155. data/lib/grape/exceptions/invalid_parameters.rb +0 -11
  156. data/lib/grape/exceptions/missing_option.rb +0 -11
  157. data/lib/grape/exceptions/too_deep_parameters.rb +0 -11
  158. data/lib/grape/exceptions/too_many_multipart_files.rb +0 -11
  159. data/lib/grape/exceptions/unknown_options.rb +0 -11
  160. data/lib/grape/extensions/active_support/hash_with_indifferent_access.rb +0 -24
  161. data/lib/grape/extensions/hash.rb +0 -27
  162. data/lib/grape/extensions/hashie/mash.rb +0 -24
  163. data/lib/grape/middleware/globals.rb +0 -14
  164. data/lib/grape/types/invalid_value.rb +0 -8
  165. data/lib/grape/util/base_inheritable.rb +0 -43
  166. data/lib/grape/util/inheritable_values.rb +0 -29
  167. data/lib/grape/util/reverse_stackable_values.rb +0 -15
  168. data/lib/grape/util/strict_hash_configuration.rb +0 -108
  169. data/lib/grape/validations/attributes_doc.rb +0 -60
  170. data/lib/grape/validations/validator_factory.rb +0 -15
@@ -3,9 +3,40 @@
3
3
  module Grape
4
4
  module Middleware
5
5
  class Formatter < Base
6
- DEFAULT_OPTIONS = {
7
- default_format: :txt
8
- }.freeze
6
+ include PrecomputedContentTypes
7
+
8
+ Options = Data.define(:content_types, :default_format, :format, :formatters, :parsers) do
9
+ def initialize(content_types: nil, default_format: :txt, format: nil, formatters: nil, parsers: nil)
10
+ super
11
+ end
12
+ end
13
+
14
+ ALL_MEDIA_TYPES = '*/*'
15
+
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
9
40
 
10
41
  def before
11
42
  negotiate_content_type
@@ -15,19 +46,17 @@ module Grape
15
46
  def after
16
47
  return unless @app_response
17
48
 
18
- status, headers, bodies = *@app_response
49
+ status, headers, bodies = @app_response
19
50
 
20
- if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status)
21
- [status, headers, []]
22
- else
23
- build_formatted_response(status, headers, bodies)
24
- end
51
+ return [status, headers, []] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status)
52
+
53
+ build_formatted_response(status, headers, bodies)
25
54
  end
26
55
 
27
56
  private
28
57
 
29
58
  def build_formatted_response(status, headers, bodies)
30
- headers = ensure_content_type(headers)
59
+ ensure_content_type!(headers)
31
60
 
32
61
  if bodies.is_a?(Grape::ServeStream::StreamResponse)
33
62
  Grape::ServeStream::SendfileResponse.new([], status, headers) do |resp|
@@ -35,44 +64,64 @@ module Grape
35
64
  end
36
65
  else
37
66
  # Allow content-type to be explicitly overwritten
38
- formatter = fetch_formatter(headers, options)
39
- bodymap = ActiveSupport::Notifications.instrument('format_response.grape', formatter: formatter, env: env) do
40
- bodies.collect { |body| formatter.call(body, env) }
67
+ formatter = fetch_formatter(headers)
68
+ bodymap = instrument_format_response(formatter) do
69
+ bodies.map { |body| formatter.call(body, env) }
41
70
  end
42
- Rack::Response.new(bodymap, status, headers)
71
+ # A bare Rack tuple rather than a Rack::Response: +headers+ is already
72
+ # a Grape::Util::Header (a Rack::Headers on Rack 3), so wrapping only
73
+ # re-normalizes the same keys into a second Headers hash that
74
+ # +Middleware::Base#call+ unwraps again with +to_a+ on the way out.
75
+ # The 204/304 bodies Rack::Response#finish would blank are returned
76
+ # above, before this point.
77
+ [status, headers, bodymap]
43
78
  end
44
79
  rescue Grape::Exceptions::InvalidFormatter => e
45
- throw :error, status: 500, message: e.message, backtrace: e.backtrace, original_exception: e
80
+ throw :error, Grape::Exceptions::ErrorResponse.new(status: 500, message: e.message, backtrace: e.backtrace, original_exception: e)
46
81
  end
47
82
 
48
- def fetch_formatter(headers, options)
83
+ # Guards on +listening?+ so that with no subscriber the payload Hash and
84
+ # notification machinery are skipped and the block runs directly (no added
85
+ # allocations); the block is forwarded anonymously.
86
+ def instrument_format_response(formatter, &)
87
+ return yield unless ActiveSupport::Notifications.notifier.listening?('format_response.grape')
88
+
89
+ ActiveSupport::Notifications.instrument('format_response.grape', formatter:, env:, &)
90
+ end
91
+
92
+ def fetch_formatter(headers)
49
93
  api_format = env.fetch(Grape::Env::API_FORMAT) { mime_types[headers[Rack::CONTENT_TYPE]] }
50
- Grape::Formatter.formatter_for(api_format, options[:formatters])
94
+ Grape::Formatter.formatter_for(api_format, formatters)
51
95
  end
52
96
 
53
97
  # Set the content type header for the API format if it is not already present.
54
98
  #
55
- # @param headers [Hash]
56
- # @return [Hash]
57
- def ensure_content_type(headers)
58
- if headers[Rack::CONTENT_TYPE]
59
- headers
60
- else
61
- headers.merge(Rack::CONTENT_TYPE => content_type_for(env[Grape::Env::API_FORMAT]))
62
- end
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]
107
+
108
+ headers[Rack::CONTENT_TYPE] = content_type_for(env[Grape::Env::API_FORMAT])
63
109
  end
64
110
 
65
111
  def read_body_input
112
+ return unless read_body_input?
113
+
66
114
  input = rack_request.body # reads RACK_INPUT
67
115
  return if input.nil?
68
- return unless read_body_input?
69
116
 
70
- input.try(:rewind)
117
+ rewind = input.respond_to?(:rewind)
118
+
119
+ input.rewind if rewind
71
120
  body = env[Grape::Env::API_REQUEST_INPUT] = input.read
72
121
  begin
73
122
  read_rack_input(body)
74
123
  ensure
75
- input.try(:rewind)
124
+ input.rewind if rewind
76
125
  end
77
126
  end
78
127
 
@@ -80,28 +129,33 @@ module Grape
80
129
  return if body.empty?
81
130
 
82
131
  media_type = rack_request.media_type
83
- fmt = media_type ? mime_types[media_type] : options[:default_format]
84
-
85
- throw :error, status: 415, message: "The provided content-type '#{media_type}' is not supported." unless content_type_for(fmt)
86
- parser = Grape::Parser.parser_for fmt, options[:parsers]
87
- if parser
88
- begin
89
- body = (env[Grape::Env::API_REQUEST_BODY] = parser.call(body, env))
90
- if body.is_a?(Hash)
91
- env[Rack::RACK_REQUEST_FORM_HASH] = if env.key?(Rack::RACK_REQUEST_FORM_HASH)
92
- env[Rack::RACK_REQUEST_FORM_HASH].merge(body)
93
- else
94
- body
95
- end
96
- env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT]
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
+
139
+ fmt = media_type ? mime_types[media_type] : default_format
140
+
141
+ 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
+
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
97
152
  end
98
- rescue Grape::Exceptions::Base => e
99
- raise e
100
- rescue StandardError => e
101
- throw :error, status: 400, message: e.message, backtrace: e.backtrace, original_exception: e
153
+ env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT]
102
154
  end
103
- else
104
- env[Grape::Env::API_REQUEST_BODY] = body
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)
105
159
  end
106
160
  end
107
161
 
@@ -112,35 +166,70 @@ module Grape
112
166
  # - multipart/related
113
167
  # - multipart/mixed
114
168
  def read_body_input?
115
- (rack_request.post? || rack_request.put? || rack_request.patch? || rack_request.delete?) &&
116
- !(rack_request.form_data? && rack_request.content_type) &&
117
- !rack_request.parseable_data? &&
118
- (rack_request.content_length.to_i.positive? || rack_request.env['HTTP_TRANSFER_ENCODING'] == 'chunked')
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])
174
+ return false if rack_request.form_data? && rack_request.content_type
175
+ return false if rack_request.parseable_data?
176
+
177
+ rack_request.content_length.to_i.positive? || env['HTTP_TRANSFER_ENCODING'] == 'chunked'
119
178
  end
120
179
 
121
180
  def negotiate_content_type
122
- fmt = format_from_extension || query_params['format'] || options[:format] || format_from_header || options[:default_format]
123
- if content_type_for(fmt)
124
- env[Grape::Env::API_FORMAT] = fmt.to_sym
125
- else
126
- throw :error, status: 406, message: "The requested format '#{fmt}' is not supported."
127
- end
181
+ fmt = format_from_extension || format_from_query || format || format_from_header || default_format
182
+ return env[Grape::Env::API_FORMAT] = fmt.to_sym if content_type_for(fmt)
183
+
184
+ throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, message: "The requested format '#{fmt}' is not supported.")
128
185
  end
129
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.
130
192
  def format_from_extension
131
- request_path = rack_request.path.try(:scrub)
193
+ request_path = path_for_extension
132
194
  dot_pos = request_path.rindex('.')
133
195
  return unless dot_pos
134
196
 
135
- extension = request_path[dot_pos + 1..]
197
+ extension = try_scrub(request_path[(dot_pos + 1)..])
136
198
  extension if content_type_for(extension)
137
199
  end
138
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.
139
228
  def format_from_header
140
- accept_header = env['HTTP_ACCEPT'].try(:scrub)
141
- return if accept_header.blank?
229
+ accept_header = try_scrub(env['HTTP_ACCEPT'])
230
+ return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES
142
231
 
143
- 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)
144
233
  mime_types[media_type] if media_type
145
234
  end
146
235
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Middleware
5
+ # Include in a middleware subclass that needs content-type negotiation.
6
+ # Provides +content_types+ / +mime_types+ / +content_type_for+ /
7
+ # +content_type+ resolved from +config.content_types+ and
8
+ # +config.format+ — so the consuming middleware's +Options+ Data class
9
+ # must declare both fields. Warms those caches on the parent instance
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.
18
+ #
19
+ # Opt-in: plain +Grape::Middleware::Base+ subclasses that don't need
20
+ # content-type-aware helpers don't pay for them.
21
+ module PrecomputedContentTypes
22
+ def initialize(app, **options)
23
+ super
24
+ content_types
25
+ content_types_lookup
26
+ end
27
+
28
+ def content_types
29
+ @content_types ||= Grape::ContentTypes.content_types_for(config.content_types)
30
+ end
31
+
32
+ def mime_types
33
+ @mime_types ||= Grape::ContentTypes.mime_types_for(content_types)
34
+ end
35
+
36
+ def content_type_for(format)
37
+ content_types_lookup[format]
38
+ end
39
+
40
+ def content_type
41
+ content_type_for(env[Grape::Env::API_FORMAT] || config.format) || 'text/html'
42
+ end
43
+
44
+ private
45
+
46
+ def content_types_lookup
47
+ @content_types_lookup ||= Grape::ContentTypes.lookup_for(content_types)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -6,6 +6,7 @@ module Grape
6
6
  # It allows to insert and insert after
7
7
  class Stack
8
8
  extend Forwardable
9
+
9
10
  class Middleware
10
11
  attr_reader :args, :block, :klass
11
12
 
@@ -27,6 +28,16 @@ module Grape
27
28
  klass == other || (name.nil? && klass.superclass == other)
28
29
  end
29
30
  end
31
+ alias eql? ==
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
30
41
 
31
42
  def inspect
32
43
  klass.to_s
@@ -53,15 +64,15 @@ module Grape
53
64
  end
54
65
 
55
66
  def insert(index, klass, *args, &block)
56
- index = assert_index(index, :before)
57
- middlewares.insert(index, self.class::Middleware.new(klass, args, block))
67
+ at = assert_index(index, :before)
68
+ middlewares.insert(at, self.class::Middleware.new(klass, args, block))
58
69
  end
59
70
 
60
71
  alias insert_before insert
61
72
 
62
- def insert_after(index, *args, &block)
63
- index = assert_index(index, :after)
64
- insert(index + 1, *args, &block)
73
+ def insert_after(index, ...)
74
+ at = assert_index(index, :after)
75
+ insert(at + 1, ...)
65
76
  end
66
77
 
67
78
  def use(klass, *args, &block)
@@ -82,12 +93,10 @@ module Grape
82
93
 
83
94
  # @return [Rack::Builder] the builder object with our middlewares applied
84
95
  def build
85
- Rack::Builder.new.tap do |builder|
86
- others.shift(others.size).each { |m| merge_with(m) }
87
- middlewares.each do |m|
88
- m.build(builder)
89
- end
90
- end
96
+ builder = Rack::Builder.new
97
+ others.shift(others.size).each { |m| merge_with(m) }
98
+ middlewares.each { |m| m.build(builder) }
99
+ builder
91
100
  end
92
101
 
93
102
  # @description Add middlewares with :use operation to the stack. Store others with :insert_* operation for later
@@ -18,8 +18,8 @@ module Grape
18
18
  # route.
19
19
  class AcceptVersionHeader < Base
20
20
  def before
21
- potential_version = env['HTTP_ACCEPT_VERSION'].try(:scrub)
22
- not_acceptable!('Accept-Version header must be set.') if strict? && potential_version.blank?
21
+ potential_version = try_scrub(env['HTTP_ACCEPT_VERSION'])
22
+ not_acceptable!('Accept-Version header must be set.') if strict && potential_version.blank?
23
23
 
24
24
  return if potential_version.blank?
25
25
 
@@ -30,7 +30,7 @@ module Grape
30
30
  private
31
31
 
32
32
  def not_acceptable!(message)
33
- throw :error, status: 406, headers: error_headers, message: message
33
+ throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, headers: error_headers, message:)
34
34
  end
35
35
  end
36
36
  end
@@ -4,69 +4,61 @@ module Grape
4
4
  module Middleware
5
5
  module Versioner
6
6
  class Base < Grape::Middleware::Base
7
- DEFAULT_OPTIONS = {
8
- pattern: /.*/i.freeze,
9
- version_options: {
10
- strict: false,
11
- cascade: true,
12
- parameter: 'apiver'
13
- }.freeze
14
- }.freeze
7
+ extend Forwardable
8
+ include Grape::Middleware::PrecomputedContentTypes
15
9
 
16
- def self.inherited(klass)
17
- super
18
- Versioner.register(klass)
10
+ Options = Data.define(
11
+ :content_types, :format, :mount_path, :prefix, :version_options, :versions
12
+ ) do
13
+ def initialize(
14
+ content_types: nil, format: nil, mount_path: nil, prefix: nil,
15
+ version_options: Grape::DSL::VersionOptions.new, versions: nil
16
+ )
17
+ super
18
+ end
19
19
  end
20
20
 
21
- def versions
22
- options[:versions]
23
- end
24
-
25
- def prefix
26
- options[:prefix]
27
- end
21
+ CASCADE_PASS_HEADER = { 'X-Cascade' => 'pass' }.freeze
28
22
 
29
- def mount_path
30
- options[:mount_path]
31
- end
32
-
33
- def pattern
34
- options[:pattern]
35
- end
36
-
37
- def version_options
38
- options[:version_options]
23
+ def self.inherited(klass)
24
+ super
25
+ Versioner.register(klass)
39
26
  end
40
27
 
41
- def strict?
42
- version_options[:strict]
43
- end
28
+ attr_reader :available_media_types, :error_headers, :versions
44
29
 
45
- # By default those errors contain an `X-Cascade` header set to `pass`, which allows nesting and stacking
46
- # of routes (see Grape::Router) for more information). To prevent
47
- # this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`.
48
- def cascade?
49
- version_options[:cascade]
50
- end
30
+ def_delegators :config, :mount_path, :prefix, :version_options
31
+ def_delegators :version_options, :cascade, :parameter, :strict, :vendor
51
32
 
52
- def parameter_key
53
- version_options[:parameter]
33
+ def initialize(app, **options)
34
+ super
35
+ @versions = config.versions&.map(&:to_s) # making sure versions are strings to ease potential match
36
+ @error_headers = cascade ? CASCADE_PASS_HEADER : {}
37
+ @available_media_types = build_available_media_types
54
38
  end
55
39
 
56
- def vendor
57
- version_options[:vendor]
40
+ def potential_version_match?(potential_version)
41
+ versions.blank? || versions.include?(potential_version)
58
42
  end
59
43
 
60
- def error_headers
61
- cascade? ? { 'X-Cascade' => 'pass' } : {}
44
+ def version_not_found!
45
+ throw :error, Grape::Exceptions::ErrorResponse.new(status: 404, message: '404 API Version Not Found', headers: CASCADE_PASS_HEADER)
62
46
  end
63
47
 
64
- def potential_version_match?(potential_version)
65
- versions.blank? || versions.any? { |v| v.to_s == potential_version }
66
- end
48
+ private
67
49
 
68
- def version_not_found!
69
- throw :error, status: 404, message: '404 API Version Not Found', headers: { 'X-Cascade' => 'pass' }
50
+ def build_available_media_types
51
+ media_types = []
52
+ 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
60
+ media_types.concat(content_types.values.flatten)
61
+ media_types
70
62
  end
71
63
  end
72
64
  end
@@ -41,11 +41,9 @@ module Grape
41
41
 
42
42
  strict_header_checks!
43
43
  media_type = Grape::Util::MediaType.best_quality(accept_header, available_media_types)
44
- if media_type
45
- yield media_type
46
- else
47
- fail!
48
- end
44
+ return yield media_type if media_type
45
+
46
+ fail!
49
47
  end
50
48
 
51
49
  def accept_header
@@ -53,7 +51,7 @@ module Grape
53
51
  end
54
52
 
55
53
  def strict_header_checks!
56
- return unless strict?
54
+ return unless strict
57
55
 
58
56
  accept_header_check!
59
57
  version_and_vendor_check!
@@ -101,26 +99,10 @@ module Grape
101
99
  end
102
100
 
103
101
  def version_not_found!(media_types)
104
- return unless media_types.all? { |media_type| media_type&.version && versions&.exclude?(media_type.version) }
102
+ return unless media_types.all? { |media_type| media_type&.version && versions && !versions.include?(media_type.version) }
105
103
 
106
104
  invalid_version_header!('API version not found.')
107
105
  end
108
-
109
- def available_media_types
110
- [].tap do |available_media_types|
111
- base_media_type = "application/vnd.#{vendor}"
112
- content_types.each_key do |extension|
113
- versions&.reverse_each do |version|
114
- available_media_types << "#{base_media_type}-#{version}+#{extension}"
115
- available_media_types << "#{base_media_type}-#{version}"
116
- end
117
- available_media_types << "#{base_media_type}+#{extension}"
118
- end
119
-
120
- available_media_types << base_media_type
121
- available_media_types.concat(content_types.values.flatten)
122
- end
123
- end
124
106
  end
125
107
  end
126
108
  end
@@ -20,11 +20,11 @@ module Grape
20
20
  # env['api.version'] => 'v1'
21
21
  class Param < Base
22
22
  def before
23
- potential_version = query_params[parameter_key]
23
+ potential_version = query_params[parameter]
24
24
  return if potential_version.blank?
25
25
 
26
26
  version_not_found! unless potential_version_match?(potential_version)
27
- env[Grape::Env::API_VERSION] = env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter_key)
27
+ env[Grape::Env::API_VERSION] = env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter)
28
28
  end
29
29
  end
30
30
  end