Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#### Fixes

* [#2655](https://github.com/ruby-grape/grape/pull/2655): Fix `before_each` method to handle `nil` parameter correctly - [@ericproulx](https://github.com/ericproulx).
* [#2657](https://github.com/ruby-grape/grape/pull/2657): Fix thread safety: move mutable `ParamsScope` state (`index`, `params_meeting_dependency`) into a per-request `ParamScopeTracker` stored in `Fiber[]` - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 3.1.0 (2026-01-25)
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/dsl/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def map_params(params, element, is_array = false)
# @return hash of parameters relevant for the current scope
# @api private
def params(params)
params = @parent.params_meeting_dependency.presence || @parent.params(params) if @parent
params = @parent.qualifying_params.presence || @parent.params(params) if @parent
params = map_params(params, @element) if @element
params
end
Expand Down
20 changes: 11 additions & 9 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,17 @@ def execute
def run_validators(validators, request)
validation_errors = []

ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) do
validators.each do |validator|
validator.validate(request)
rescue Grape::Exceptions::Validation => e
validation_errors << e
break if validator.fail_fast?
rescue Grape::Exceptions::ValidationArrayErrors => e
validation_errors.concat e.errors
break if validator.fail_fast?
Grape::Validations::ParamScopeTracker.track do
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) do
validators.each do |validator|
validator.validate(request)
rescue Grape::Exceptions::Validation => e
validation_errors << e
break if validator.fail_fast?
rescue Grape::Exceptions::ValidationArrayErrors => e
validation_errors.concat e.errors
break if validator.fail_fast?
end
end
end

Expand Down
43 changes: 29 additions & 14 deletions lib/grape/validations/attributes_iterator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,54 @@ def each(&)

private

def do_each(params_to_process, parent_indicies = [], &block)
@scope.reset_index # gets updated depending on the size of params_to_process
def do_each(params_to_process, parent_indices = [], &block)
params_to_process.each_with_index do |resource_params, index|
# when we get arrays of arrays it means that target element located inside array
# we need this because we want to know parent arrays indicies
# we need this because we want to know parent arrays indices
if resource_params.is_a?(Array)
do_each(resource_params, [index] + parent_indicies, &block)
do_each(resource_params, [index] + parent_indices, &block)
next
end

if @scope.type == Array
next unless @original_params.is_a?(Array) # do not validate content of array if it isn't array

# fill current and parent scopes with correct array indicies
parent_scope = @scope.parent
parent_indicies.each do |parent_index|
parent_scope.index = parent_index
parent_scope = parent_scope.parent
end
@scope.index = index
store_indices(@scope, index, parent_indices)
elsif @original_params.is_a?(Array)
# Lateral scope (no @element) whose params resolved to an array —
# delegate index tracking to the nearest array-typed ancestor so
# that full_name produces the correct bracketed index.
target = @scope.nearest_array_ancestor
store_indices(target, index, parent_indices) if target
end

yield_attributes(resource_params, @attrs, &block)
end
end

def store_indices(target_scope, index, parent_indices)
# No tracker means we're outside a ParamScopeTracker.track block (e.g.
# a unit test that invokes a validator directly). Index tracking is
# skipped — full_name will produce bracket-less names — but validation
# continues rather than crashing.
tracker = ParamScopeTracker.current or return
parent_scope = target_scope.parent
parent_indices.each do |parent_index|
break unless parent_scope

tracker.store_index(parent_scope, parent_index)
parent_scope = parent_scope.parent
end
tracker.store_index(target_scope, index)
end

def yield_attributes(_resource_params, _attrs)
raise NotImplementedError
end

# This is a special case so that we can ignore tree's where option
# values are missing lower down. Unfortunately we can remove this
# are the parameter parsing stage as they are required to ensure
# This is a special case so that we can ignore trees where option
# values are missing lower down. Unfortunately we can't remove this
# at the parameter parsing stage as they are required to ensure
# the correct indexing is maintained
def skip?(val)
val == Grape::DSL::Parameters::EmptyOptionalValue
Expand Down
57 changes: 57 additions & 0 deletions lib/grape/validations/param_scope_tracker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

module Grape
module Validations
# Holds per-request mutable state that must not live on shared ParamsScope
# instances. Both trackers are identity-keyed hashes so that ParamsScope
# objects can serve as keys without relying on value equality.
#
# Lifecycle is managed by Endpoint#run_validators via +.track+.
# Use +.current+ to access the instance for the running request.
class ParamScopeTracker
# Fiber-local key used to store the current tracker.
# Fiber[] (Ruby 3.0+) is used instead of Thread.current[] so that
# fiber-based servers (e.g. Falcon with async) isolate each request's
# tracker within its own fiber rather than sharing state across all
# fibers running on the same thread.
FIBER_KEY = :grape_param_scope_tracker
EMPTY_PARAMS = [].freeze

def self.track
previous = Fiber[FIBER_KEY]
Fiber[FIBER_KEY] = new
yield
ensure
Fiber[FIBER_KEY] = previous
end

def self.current
Fiber[FIBER_KEY]
end

def initialize
@index_tracker = {}.compare_by_identity
@qualifying_params_tracker = {}.compare_by_identity
end

def store_index(scope, index)
@index_tracker.store(scope, index)
end

def index_for(scope)
@index_tracker[scope]
end

# Returns qualifying params for +scope+, or EMPTY_PARAMS if none were stored.
# Note: an explicitly stored empty array and "never stored" are treated identically
# by callers (both yield a blank result that falls through to the parent params).
def qualifying_params(scope)
@qualifying_params_tracker.fetch(scope, EMPTY_PARAMS)
end

def store_qualifying_params(scope, params)
@qualifying_params_tracker.store(scope, params)
end
end
end
end
32 changes: 20 additions & 12 deletions lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
module Grape
module Validations
class ParamsScope
attr_accessor :element, :parent, :index
attr_reader :type, :params_meeting_dependency
attr_accessor :element, :parent
attr_reader :type, :nearest_array_ancestor

def qualifying_params
ParamScopeTracker.current&.qualifying_params(self)
end

include Grape::DSL::Parameters
include Grape::Validations::ParamsDocumentation
Expand Down Expand Up @@ -70,13 +74,12 @@ def initialize(opts, &block)
@type = opts[:type]
@group = opts[:group]
@dependent_on = opts[:dependent_on]
@params_meeting_dependency = []
@declared_params = []
@index = nil

instance_eval(&block) if block

configure_declared_params
@nearest_array_ancestor = find_nearest_array_ancestor
end

def configuration
Expand All @@ -100,8 +103,9 @@ def meets_dependency?(params, request_params)
return false if @parent.present? && !@parent.meets_dependency?(@parent.params(request_params), request_params)

if params.is_a?(Array)
@params_meeting_dependency = params.flatten.filter { |param| meets_dependency?(param, request_params) }
return @params_meeting_dependency.present?
filtered = params.flatten.filter { |param| meets_dependency?(param, request_params) }
ParamScopeTracker.current&.store_qualifying_params(self, filtered)
return filtered.present?
end

meets_hash_dependency?(params)
Expand Down Expand Up @@ -133,13 +137,15 @@ def meets_hash_dependency?(params)

# @return [String] the proper attribute name, with nesting considered.
def full_name(name, index: nil)
tracker = ParamScopeTracker.current
if nested?
# Find our containing element's name, and append ours.
"#{@parent.full_name(@element)}#{brackets(index || @index)}#{brackets(name)}"
resolved_index = index || tracker&.index_for(self)
"#{@parent.full_name(@element)}#{brackets(resolved_index)}#{brackets(name)}"
elsif lateral?
# Find the name of the element as if it was at the same nesting level
# as our parent. We need to forward our index upward to achieve this.
@parent.full_name(name, index: @index)
@parent.full_name(name, index: tracker&.index_for(self))
else
# We must be the root scope, so no prefix needed.
name.to_s
Expand Down Expand Up @@ -174,10 +180,6 @@ def required?
!@optional
end

def reset_index
@index = nil
end

protected

# Adds a parameter declaration to our list of validations.
Expand Down Expand Up @@ -329,6 +331,12 @@ def configure_declared_params
@declared_params = nil
end

def find_nearest_array_ancestor
scope = @parent
scope = scope.parent while scope && scope.type != Array
scope
end

def validates(attrs, validations)
coerce_type = infer_coercion(validations)
required = validations.key?(:presence)
Expand Down
2 changes: 1 addition & 1 deletion spec/grape/dsl/parameters_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ def extract_message_option(attrs)
it 'inherits params from parent' do
parent_params = { foo: 'bar' }
subject.parent = Object.new
allow(subject.parent).to receive_messages(params: parent_params, params_meeting_dependency: nil)
allow(subject.parent).to receive_messages(params: parent_params, qualifying_params: nil)
expect(subject.params({})).to eq parent_params
end

Expand Down
121 changes: 121 additions & 0 deletions spec/grape/validations/param_scope_tracker_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# frozen_string_literal: true

describe Grape::Validations::ParamScopeTracker do
describe '.current' do
it 'returns nil when no tracker is active' do
expect(described_class.current).to be_nil
end
end

describe '.track' do
it 'sets .current inside the block' do
described_class.track do
expect(described_class.current).to be_a(described_class)
end
end

it 'restores nil after the block' do
described_class.track { nil }
expect(described_class.current).to be_nil
end

it 'restores nil after an exception' do
expect { described_class.track { raise 'boom' } }.to raise_error('boom')
expect(described_class.current).to be_nil
end

it 'creates a fresh tracker for each invocation' do
first = nil
second = nil
described_class.track { first = described_class.current }
described_class.track { second = described_class.current }
expect(first).not_to equal(second)
end

context 'when nested (reentrant)' do
it 'restores the outer tracker, not nil' do
outer = nil
inner = nil

described_class.track do
outer = described_class.current
described_class.track { inner = described_class.current }
expect(described_class.current).to equal(outer)
end

expect(inner).not_to equal(outer)
expect(described_class.current).to be_nil
end

it 'restores outer tracker after inner raises' do
described_class.track do
outer = described_class.current
expect { described_class.track { raise 'inner' } }.to raise_error('inner')
expect(described_class.current).to equal(outer)
end
end
end
end

describe '#store_index / #index_for' do
subject(:tracker) { described_class.new }

let(:scope_a) { instance_double(Grape::Validations::ParamsScope) }
let(:scope_b) { instance_double(Grape::Validations::ParamsScope) }

it 'returns nil for an unknown scope' do
expect(tracker.index_for(scope_a)).to be_nil
end

it 'returns the stored index for the given scope' do
tracker.store_index(scope_a, 3)
expect(tracker.index_for(scope_a)).to eq(3)
end

it 'stores indices independently per scope' do
tracker.store_index(scope_a, 0)
tracker.store_index(scope_b, 7)
expect(tracker.index_for(scope_a)).to eq(0)
expect(tracker.index_for(scope_b)).to eq(7)
end

it 'uses object identity, not value equality, as the key' do
equal_double = instance_double(Grape::Validations::ParamsScope)
tracker.store_index(scope_a, 1)
expect(tracker.index_for(equal_double)).to be_nil
end

it 'overwrites a previously stored index' do
tracker.store_index(scope_a, 1)
tracker.store_index(scope_a, 5)
expect(tracker.index_for(scope_a)).to eq(5)
end
end

describe '#store_qualifying_params / #qualifying_params' do
subject(:tracker) { described_class.new }

let(:scope) { instance_double(Grape::Validations::ParamsScope) }

it 'returns EMPTY_PARAMS for an unknown scope' do
expect(tracker.qualifying_params(scope)).to equal(described_class::EMPTY_PARAMS)
end

it 'returns the stored params for the given scope' do
params = [{ id: 1 }, { id: 2 }]
tracker.store_qualifying_params(scope, params)
expect(tracker.qualifying_params(scope)).to eq(params)
end

it 'treats an explicitly stored empty array the same as never stored (blank)' do
tracker.store_qualifying_params(scope, [])
expect(tracker.qualifying_params(scope).presence).to be_nil
end

it 'uses object identity as the key' do
other_scope = instance_double(Grape::Validations::ParamsScope)
tracker.store_qualifying_params(scope, [{ id: 1 }])
expect(tracker.qualifying_params(other_scope)).to equal(described_class::EMPTY_PARAMS)
end
end
end