Skip to content

Commit 1f536e4

Browse files
authored
Fix false ArgumentError when using &: with delegated or method_missing methods (#406)
Using `expose :attr, &:method_name` raised a confusing ArgumentError for methods created via ActiveSupport `delegate` or `method_missing`, because those wrappers report a variable arity that was misread as "requires arguments." The arity check now uses Method#parameters instead of Method#arity for precise detection of required positional and keyword arguments. Methods that cannot be introspected (delegation wrappers, method_missing proxies without respond_to_missing?) fall through gracefully and let Ruby raise its native error at call time. The Proc#to_s regex is strict-anchored to avoid false positives on other lambda shapes.
1 parent dddb863 commit 1f536e4

4 files changed

Lines changed: 321 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
* Your contribution here.
1010
* [#404](https://github.com/ruby-grape/grape-entity/pull/404): Drop `MultiJson` dependency, use `Hash#to_json` for ActiveSupport-aware serialization - [@numbata](https://github.com/numbata).
11+
* [#406](https://github.com/ruby-grape/grape-entity/pull/406): Handle symbol-to-proc wrappers (`&:method_name`) where the method uses `delegate` or `method_missing`, and let unknown methods raise a native `NoMethodError` - [@marcrohloff](https://github.com/marcrohloff).
1112

1213
### 1.0.4 (2026-04-17)
1314

lib/grape_entity/entity.rb

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ def self.documentation
291291
end
292292

293293
# This allows you to declare a Proc in which exposures can be formatted with.
294-
# It take a block with an arity of 1 which is passed as the value of the exposed attribute.
294+
# It takes a block with a single argument which is passed as the value of the exposed attribute.
295295
#
296296
# @param name [Symbol] the name of the formatter
297297
# @param block [Proc] the block that will interpret the exposed attribute
@@ -534,26 +534,53 @@ def exec_with_object(options, &block)
534534
end
535535

536536
def ensure_block_arity!(block)
537-
# MRI currently always includes "( &:foo )" for symbol-to-proc wrappers.
538-
# If this format changes in a new Ruby version, this logic must be updated.
539-
origin_method_name = block.to_s.scan(/(?<=\(&:)[^)]+(?=\))/).first&.to_sym
540-
return unless origin_method_name
541-
542-
unless object.respond_to?(origin_method_name, true)
543-
raise ArgumentError, <<~MSG
544-
Cannot use `&:#{origin_method_name}` because that method is not defined in the object.
545-
MSG
546-
end
537+
# Strict anchor to match MRI Proc#to_s format for symbol-to-proc: #<Proc:0x0...(&:method_name) (lambda)>
538+
match = block.to_s.match(/\A#<Proc:(?:0x)?\h+\(&:(?<name>.+)\) \(lambda\)>\z/)
539+
return unless match # Unrecognized format -> bail safe rather than misidentify
540+
541+
origin_method_name = match[:name].to_sym
542+
required_positional_arg_count, required_keyword_arg_count, variadic_positional =
543+
arity_requirement_for(origin_method_name)
544+
return unless required_positional_arg_count
547545

548-
arity = object.method(origin_method_name).arity
549-
return if arity.zero?
546+
required_arguments =
547+
required_arguments_summary(required_positional_arg_count, required_keyword_arg_count, variadic_positional)
550548

551549
raise ArgumentError, <<~MSG
552-
Cannot use `&:#{origin_method_name}` because that method expects #{arity} argument#{'s' if arity != 1}.
553-
Symbol‐to‐proc shorthand only works for zero‐argument methods.
550+
Cannot use `&:#{origin_method_name}` because that method expects #{required_arguments}.
551+
Symbol-to-proc shorthand only works for methods that can be called with no arguments.
554552
MSG
555553
end
556554

555+
def arity_requirement_for(method_name)
556+
origin_method = object.method(method_name)
557+
parameters = origin_method.parameters
558+
559+
required_positional_arg_count = parameters.count { |type, _| type == :req }
560+
required_keyword_arg_count = parameters.count { |type, _| type == :keyreq }
561+
return nil if required_positional_arg_count.zero? && required_keyword_arg_count.zero?
562+
563+
[required_positional_arg_count, required_keyword_arg_count, parameters.any? { |type, _| type == :rest }]
564+
rescue NameError
565+
# Delegation wrappers and method_missing proxies may not expose a Method; let Ruby raise natively at call time.
566+
nil
567+
end
568+
569+
def required_arguments_summary(required_positional_arg_count, required_keyword_arg_count, variadic_positional)
570+
parts = []
571+
unless required_positional_arg_count.zero?
572+
suffix = required_positional_arg_count == 1 ? 'argument' : 'arguments'
573+
suffix += ' or more' if variadic_positional
574+
parts << "#{required_positional_arg_count} #{suffix}"
575+
end
576+
unless required_keyword_arg_count.zero?
577+
suffix = required_keyword_arg_count == 1 ? 'keyword argument' : 'keyword arguments'
578+
parts << "#{required_keyword_arg_count} #{suffix}"
579+
end
580+
581+
parts.join(' and ')
582+
end
583+
557584
def symbol_to_proc_wrapper?(block)
558585
params = block.parameters
559586

0 commit comments

Comments
 (0)