grape 3.3.2 → 3.3.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47f5b2c65d5d4fb054f4b602fd97bd02be043e1ae40ded0d52dc70c2888a4ca7
4
- data.tar.gz: f6bb78e2afc50d777dc0edf0447098908b6532c7e523f0291e693aacbff065c6
3
+ metadata.gz: 0d535091a3993a05695219506182f9259f499ee04379dfc023467c22fb56c47b
4
+ data.tar.gz: 2f2d84b73bed8ce3425e601f4619ea53b0287a6a466ef8b2a7c2fcb501bb980a
5
5
  SHA512:
6
- metadata.gz: 910ad023e4cbfca1ba429adcccaa71018e5bad3f88e2195fe8becb26aa4736ed18b5b87f997e71fa11d4211e817f5b6d2aff518fda31ad93105b6e51c6f4d4b7
7
- data.tar.gz: 85f489446889da6c3645842672790246e66b636d63af6d3734a8bbaa5163c3ec22086e6e066cda8eac52dfd1c4f4834f4536aab39211585297ab25b1efa4d6d9
6
+ metadata.gz: f0c882bfce6eef4f8ff9942a60dd06841cea7e01cf93d2b9af1427b766ec86636f14f31941a04c7ea844ff08cef2e4f720bd2d45bf6f61c4d8ccd983ea55bde8
7
+ data.tar.gz: 8c974d32f17599683db1ca2902c82c793013c87628f3fcd16be105d5c930109a9834fe7445411aec06e94c2e5635bd90441bc93081ceaca11c256c7f4e4f3cbd
data/CHANGELOG.md CHANGED
@@ -1,9 +1,17 @@
1
+ ### 3.3.3 (2026-07-17)
2
+
3
+ #### Fixes
4
+
5
+ * [#2794](https://github.com/ruby-grape/grape/pull/2794): Fix `rescue_from Klass, rescue_subclasses: false` never rescuing — base-only handlers were written to and read from different settings stores - [@ericproulx](https://github.com/ericproulx).
6
+ * [#2789](https://github.com/ruby-grape/grape/pull/2789): Escape error messages served with a parameterized `text/html` content-type (e.g. `text/html; charset=utf-8`) and warn when a bare Rack app is mounted under authentication - [@ericproulx](https://github.com/ericproulx).
7
+ * [#2790](https://github.com/ruby-grape/grape/pull/2790): Stop mutating `Grape::Router`'s internal maps at request time for HTTP methods with no routes (data race on JRuby/TruffleRuby and unbounded growth) - [@ericproulx](https://github.com/ericproulx).
8
+ * [#2791](https://github.com/ruby-grape/grape/pull/2791): Build HEAD/OPTIONS/405 helper routes from a copy of the inheritable settings instead of temporarily mutating the shared class-level settings during compilation - [@ericproulx](https://github.com/ericproulx).
9
+
1
10
  ### 3.3.2 (2026-07-05)
2
11
 
3
12
  #### Fixes
4
13
 
5
14
  * [#2773](https://github.com/ruby-grape/grape/pull/2773): Fix middleware build crash when a top-level `::Options` constant is in scope - [@ericproulx](https://github.com/ericproulx).
6
- * Your contribution here.
7
15
 
8
16
  ### 3.3.1 (2026-06-28)
9
17
 
data/README.md CHANGED
@@ -10,7 +10,7 @@ Grape is a REST-like API framework for Ruby. It's designed to run on Rack or com
10
10
 
11
11
  ## Stable Release
12
12
 
13
- You're reading the documentation for the stable release of Grape, 3.3.2.
13
+ You're reading the documentation for the stable release of Grape, 3.3.3.
14
14
 
15
15
  ## Project Resources
16
16
 
@@ -5,6 +5,8 @@ module Grape
5
5
  # The API Instance class, is the engine behind Grape::API. Each class that inherits
6
6
  # from this will represent a different API instance
7
7
  class Instance
8
+ # Marks this and every mounted instance as a mountable Grape app (see Grape::Mountable).
9
+ extend Grape::Mountable
8
10
  extend Grape::DSL::Settings
9
11
  extend Grape::DSL::Desc
10
12
  extend Grape::DSL::Validations
@@ -150,14 +152,19 @@ module Grape
150
152
  # contain already versioning information when using path versioning.
151
153
  all_routes = self.class.endpoints.flat_map(&:routes)
152
154
 
153
- # Disable versioning so adding a route won't prepend versioning
154
- # informations again.
155
- without_root_prefix_and_versioning { collect_route_config_per_pattern(all_routes) }
155
+ # Read the settings the helper routes need from a *copy* with the
156
+ # root-prefix/versioning keys stripped, so adding these routes won't
157
+ # prepend versioning information again. This used to delete those keys
158
+ # from the shared class-level settings and restore them in an ensure;
159
+ # a request served concurrently on another instance during a runtime
160
+ # recompile could observe the missing keys (e.g. via #cascade?). A
161
+ # local copy keeps that mutation off the shared object.
162
+ namespace_inheritable = self.class.inheritable_setting.namespace_inheritable.to_hash.except(*ROOT_PREFIX_VERSIONING_KEYS)
163
+ collect_route_config_per_pattern(all_routes, namespace_inheritable)
156
164
  end
157
165
 
158
- def collect_route_config_per_pattern(all_routes)
166
+ def collect_route_config_per_pattern(all_routes, namespace_inheritable)
159
167
  routes_by_regexp = all_routes.group_by(&:pattern_regexp)
160
- namespace_inheritable = self.class.inheritable_setting.namespace_inheritable
161
168
 
162
169
  # Build the configuration based on the first endpoint and the collection of methods supported.
163
170
  routes_by_regexp.each_value do |routes|
@@ -177,18 +184,6 @@ module Grape
177
184
 
178
185
  ROOT_PREFIX_VERSIONING_KEYS = %i[version version_options root_prefix].freeze
179
186
  private_constant :ROOT_PREFIX_VERSIONING_KEYS
180
-
181
- # Allows definition of endpoints that ignore the versioning configuration
182
- # used by the rest of your API.
183
- def without_root_prefix_and_versioning
184
- inheritable_setting = self.class.inheritable_setting
185
- deleted_values = inheritable_setting.namespace_inheritable.delete(*ROOT_PREFIX_VERSIONING_KEYS)
186
- yield
187
- ensure
188
- ROOT_PREFIX_VERSIONING_KEYS.zip(deleted_values) do |key, value|
189
- inheritable_setting.namespace_inheritable[key] = value
190
- end
191
- end
192
187
  end
193
188
  end
194
189
  end
data/lib/grape/api.rb CHANGED
@@ -4,6 +4,9 @@ module Grape
4
4
  # The API class is the primary entry point for creating Grape APIs. Users
5
5
  # should subclass this class in order to build an API.
6
6
  class API
7
+ # Marks this and every subclass as a mountable Grape app (see Grape::Mountable).
8
+ extend Grape::Mountable
9
+
7
10
  # Class methods that we want to call on the API rather than on the API object
8
11
  NON_OVERRIDABLE = %i[base= base_instance? call change! configuration compile! inherit_settings recognize_path reset! routes top_level_setting= top_level_setting].freeze
9
12
 
@@ -22,7 +22,19 @@ module Grape
22
22
  def mime_types_for(from_settings)
23
23
  return MIME_TYPES if from_settings == Grape::ContentTypes::DEFAULTS
24
24
 
25
- from_settings.invert.transform_keys! { |k| k.include?(';') ? k.split(';', 2).first : k }
25
+ from_settings.invert.transform_keys! { |k| media_type(k) }
26
+ end
27
+
28
+ # The media type of a content-type header: the part before any `;`
29
+ # parameters, with surrounding whitespace removed
30
+ # (e.g. `'text/html'` for `'text/html; charset=utf-8'`). Returns nil for a
31
+ # nil content type. Skips the split (and its allocation) when there are no
32
+ # parameters, which is the common case.
33
+ def media_type(content_type)
34
+ return if content_type.nil?
35
+
36
+ base = content_type.include?(';') ? content_type.split(';', 2).first : content_type
37
+ base.strip
26
38
  end
27
39
  end
28
40
  end
@@ -133,7 +133,9 @@ module Grape
133
133
  end
134
134
  in_setting = inheritable_setting
135
135
 
136
- if app.respond_to?(:inheritable_setting, true)
136
+ # Past the mount_instance branch above, a Grape app here is an already
137
+ # instantiated Grape::API::Instance (vs. a bare Rack app).
138
+ if app.is_a?(Grape::Mountable)
137
139
  mount_path = Grape::Util::PathNormalizer.call(path)
138
140
  app.top_level_setting.namespace_stackable[:mount_path] = mount_path
139
141
 
@@ -261,6 +261,7 @@ module Grape
261
261
 
262
262
  def compile!
263
263
  @app = config.app || build_stack
264
+ warn_unauthenticated_mounted_app
264
265
  @helpers = build_helpers
265
266
  stackable = inheritable_setting.namespace_stackable
266
267
  @befores = stackable[:befores]
@@ -294,6 +295,13 @@ module Grape
294
295
  end
295
296
  end
296
297
 
298
+ # True when a bare Rack app (anything that isn't a Grape app) is mounted at
299
+ # this endpoint. Such an app is called directly and matched by path prefix
300
+ # rather than an anchored route.
301
+ def bare_rack_app?
302
+ config.app && !config.app.is_a?(Grape::Mountable)
303
+ end
304
+
297
305
  def prepare_default_route_attributes(route_options)
298
306
  {
299
307
  namespace:,
@@ -369,8 +377,8 @@ module Grape
369
377
  default_error_formatter: ns_inh[:default_error_formatter],
370
378
  error_formatters: ns_stack.namespace_stackable_with_hash(:error_formatters),
371
379
  rescue_options: ns_stack.namespace_stackable[:rescue_options]&.last,
372
- rescue_handlers:,
373
- base_only_rescue_handlers: ns_stack.namespace_stackable_with_hash(:base_only_rescue_handlers),
380
+ rescue_handlers: merged_reverse_stackable(:rescue_handlers),
381
+ base_only_rescue_handlers: merged_reverse_stackable(:base_only_rescue_handlers),
374
382
  all_rescue_handler: ns_inh[:all_rescue_handler],
375
383
  grape_exceptions_rescue_handler: ns_inh[:grape_exceptions_rescue_handler],
376
384
  internal_grape_exceptions_rescue_handler: ns_inh[:internal_grape_exceptions_rescue_handler]
@@ -384,6 +392,19 @@ module Grape
384
392
  Module.new { helpers.each { |mod_to_include| include mod_to_include } }
385
393
  end
386
394
 
395
+ # A bare Rack app mounted with +mount+ is called directly (see +compile!+):
396
+ # it does not go through +build_stack+, so the API's authentication
397
+ # middleware never runs and the mount is reachable unauthenticated. Mounted
398
+ # Grape APIs are unaffected because they rebuild their own stack from the
399
+ # inherited settings. Warn so this bypass isn't silent.
400
+ def warn_unauthenticated_mounted_app
401
+ return unless bare_rack_app?
402
+ return unless inheritable_setting.namespace_inheritable[:auth]
403
+
404
+ warn "Grape: #{config.app} is mounted under an API that declares authentication, but authentication " \
405
+ 'middleware does not wrap mounted Rack applications. Requests to this mount are not authenticated by Grape.'
406
+ end
407
+
387
408
  def build_response_cookies
388
409
  return unless request.cookies?
389
410
 
@@ -397,12 +418,16 @@ module Grape
397
418
  inheritable_setting.namespace_inheritable[:lint] || Grape.config.lint
398
419
  end
399
420
 
400
- def rescue_handlers
401
- rescue_handlers = inheritable_setting.namespace_reverse_stackable[:rescue_handlers]
402
- return if rescue_handlers.blank?
421
+ # Merge a reverse-stackable handler map (as written by +rescue_from+) into a
422
+ # single Hash. The reverse store lists child-scope handlers before inherited
423
+ # ones, and the first-wins merge keeps the child's handler for a given
424
+ # class, so a nested +rescue_from+ overrides an outer one.
425
+ def merged_reverse_stackable(key)
426
+ handlers = inheritable_setting.namespace_reverse_stackable[key]
427
+ return if handlers.blank?
403
428
 
404
- rescue_handlers.each_with_object({}) do |rescue_handler, result|
405
- result.merge!(rescue_handler) { |_k, s1, _s2| s1 }
429
+ handlers.each_with_object({}) do |handler, result|
430
+ result.merge!(handler) { |_k, s1, _s2| s1 }
406
431
  end
407
432
  end
408
433
  end
@@ -58,10 +58,21 @@ module Grape
58
58
  private
59
59
 
60
60
  def rack_response(status, headers, message)
61
- message = Rack::Utils.escape_html(message) if headers[Rack::CONTENT_TYPE] == 'text/html'
61
+ message = Rack::Utils.escape_html(message) if html_content_type?(headers[Rack::CONTENT_TYPE])
62
62
  Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers))
63
63
  end
64
64
 
65
+ # Escaping must key off the media type only, case-insensitively. Comparing
66
+ # the raw header against 'text/html' would let a parameterized value such
67
+ # as 'text/html; charset=utf-8' (or a differently-cased 'Text/HTML', which
68
+ # browsers still treat as HTML) skip escaping and reflect an unescaped
69
+ # message into an HTML response. Such a header can be set from several
70
+ # places (a registered content type, or a custom Content-Type passed to
71
+ # error!/rescue_from), but they all render here.
72
+ def html_content_type?(content_type)
73
+ Grape::ContentTypes.media_type(content_type).to_s.casecmp?('text/html')
74
+ end
75
+
65
76
  def format_message(error)
66
77
  current_format = env[Grape::Env::API_FORMAT] || format
67
78
  formatter = Grape::ErrorFormatter.formatter_for(current_format, error_formatters, default_error_formatter)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ # Marker module for a mountable Grape application. Both {Grape::API} (the
5
+ # remountable user-facing class) and {Grape::API::Instance} (the compiled
6
+ # engine) extend it, so a mounted Grape app can be told apart from a bare
7
+ # Rack app with `is_a?(Grape::Mountable)` — a single, explicit predicate
8
+ # rather than duck-typing on an incidental internal method such as
9
+ # `inheritable_setting`.
10
+ #
11
+ # `Grape::API` and `Grape::API::Instance` are not related by inheritance and
12
+ # do not even respond to the same methods (the former to `mount_instance`,
13
+ # the latter to `inheritable_setting`/`endpoints`), so there is no common
14
+ # ancestor to key an `is_a?` check on without this marker.
15
+ #
16
+ # It answers identity only ("is this a Grape app?"). Capability checks that
17
+ # go on to call a stage-specific method — e.g. `respond_to?(:endpoints)`
18
+ # before reading `endpoints` — must stay as they are, since a `Mountable`
19
+ # does not necessarily respond to every such method.
20
+ module Mountable
21
+ end
22
+ end
data/lib/grape/router.rb CHANGED
@@ -13,8 +13,11 @@ module Grape
13
13
  def initialize
14
14
  @neutral_map = []
15
15
  @neutral_regexes = []
16
- @map = Hash.new { |hash, key| hash[key] = [] }
17
- @optimized_map = Hash.new { |hash, key| hash[key] = // }
16
+ # Plain hashes with no auto-vivifying default: a lookup for an HTTP method
17
+ # that has no routes must not insert a key. `compile!` freezes both maps,
18
+ # so request-time reads never mutate shared state (see #match? / #rotation).
19
+ @map = {}
20
+ @optimized_map = {}
18
21
  end
19
22
 
20
23
  def compile!
@@ -29,11 +32,13 @@ module Grape
29
32
  optimized_map = routes.map.with_index { |route, index| route.to_regexp(index) }
30
33
  @optimized_map[method] = Regexp.union(optimized_map)
31
34
  end
35
+ @map.freeze
36
+ @optimized_map.freeze
32
37
  @compiled = true
33
38
  end
34
39
 
35
40
  def append(route)
36
- @map[route.request_method] << route
41
+ (@map[route.request_method] ||= []) << route
37
42
  end
38
43
 
39
44
  def associate_routes(greedy_route)
@@ -73,7 +78,7 @@ module Grape
73
78
 
74
79
  def rotation(input, method, env, exact_route)
75
80
  response = nil
76
- @map[method].each do |route|
81
+ @map[method]&.each do |route|
77
82
  next if exact_route == route
78
83
  next unless route.match?(input)
79
84
 
@@ -139,7 +144,7 @@ module Grape
139
144
  end
140
145
 
141
146
  def match?(input, method)
142
- @optimized_map[method].match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_index] } }
147
+ @optimized_map[method]&.match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_index] } }
143
148
  end
144
149
 
145
150
  def greedy_match?(input)
data/lib/grape/version.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Grape
4
- # The current version of Grape.
5
- VERSION = '3.3.2'
4
+ VERSION = '3.3.3'
6
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.2
4
+ version: 3.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Bleigh
@@ -188,6 +188,7 @@ files:
188
188
  - lib/grape/middleware/versioner/header.rb
189
189
  - lib/grape/middleware/versioner/param.rb
190
190
  - lib/grape/middleware/versioner/path.rb
191
+ - lib/grape/mountable.rb
191
192
  - lib/grape/namespace.rb
192
193
  - lib/grape/params_builder.rb
193
194
  - lib/grape/params_builder/base.rb
@@ -281,9 +282,9 @@ licenses:
281
282
  - MIT
282
283
  metadata:
283
284
  bug_tracker_uri: https://github.com/ruby-grape/grape/issues
284
- changelog_uri: https://github.com/ruby-grape/grape/blob/v3.3.2/CHANGELOG.md
285
- documentation_uri: https://www.rubydoc.info/gems/grape/3.3.2
286
- source_code_uri: https://github.com/ruby-grape/grape/tree/v3.3.2
285
+ changelog_uri: https://github.com/ruby-grape/grape/blob/v3.3.3/CHANGELOG.md
286
+ documentation_uri: https://www.rubydoc.info/gems/grape/3.3.3
287
+ source_code_uri: https://github.com/ruby-grape/grape/tree/v3.3.3
287
288
  rubygems_mfa_required: 'true'
288
289
  rdoc_options: []
289
290
  require_paths: