From be995c1c35b55c5449bf0ce683724e7ce6ada036 Mon Sep 17 00:00:00 2001 From: Marc Rohloff Date: Fri, 8 May 2026 09:40:15 -0600 Subject: [PATCH 1/4] Feature: Handle symbol-to-proc wrappers (`&:`) that refer to a method created using delegation or method missing These methods have an arity of -1 --- CHANGELOG.md | 2 ++ lib/grape_entity/entity.rb | 3 +- spec/grape_entity/entity_spec.rb | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b796c..9ebd9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ### Next Release +* [#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` + #### Features * Your contribution here. diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index cd14033..1ca44ef 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -548,7 +548,8 @@ def ensure_block_arity!(block) end arity = object.method(origin_method_name).arity - return if arity.zero? + # functions defined using `delegate` or `method_missing` have an arity of -1 + return if arity <= 0 raise ArgumentError, <<~MSG Cannot use `&:#{origin_method_name}` because that method expects #{arity} argument#{'s' if arity != 1}. diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index 5bdd73e..7e727fc 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -400,6 +400,14 @@ class BogusEntity < Grape::Entity describe 'blocks' do class SomeObject + class SomeObjectDelegate + def method_using_delegation + 'delegated-result' + end + end + + delegate :method_using_delegation, to: :delegate_object + def method_without_args 'result' end @@ -415,6 +423,23 @@ def method_with_multiple_args(_object, _options) def raises_argument_error raise ArgumentError, 'something different' end + + def method_missing(method, ...) + return 'missing-result' if method.to_sym == :method_using_missing + + super + end + + def delegate_object + @delegate_object ||= SomeObjectDelegate.new + end + + private + + def respond_to_missing?(method, include_private = false) + method.to_sym == :method_using_missing || + super + end end describe 'with block passed in' do @@ -459,6 +484,28 @@ def raises_argument_error end end + context 'with block passed in via & that uses `missing_method`' do + specify do + subject.expose :using_missing, &:method_using_missing + + object = SomeObject.new + expect(object.method(:method_using_missing).arity).to eq(-1) + value = subject.represent(object).value_for(:using_missing) + expect(value).to eq('missing-result') + end + end + + context 'with block passed in via & that uses `delegate`' do + specify do + subject.expose :using_delegation, &:method_using_delegation + + object = SomeObject.new + expect(object.method(:method_using_delegation).arity).to eq(-1) + value = subject.represent(object).value_for(:using_delegation) + expect(value).to eq('delegated-result') + end + end + context 'with block passed in via &' do specify do subject.expose :that_method_with_one_arg, &:method_with_one_arg From 9a21bb24e474279a555347f3691dc913c43beb10 Mon Sep 17 00:00:00 2001 From: Marc Rohloff Date: Sat, 9 May 2026 15:28:46 -0600 Subject: [PATCH 2/4] Fix CI issues --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 2 +- spec/grape_entity/entity_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ebd9ea..6568d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ### Next Release -* [#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` +* [#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` - [@marcrohloff](https://github.com/marcrohloff). #### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6023a84..655e94e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ Implement your feature or bug fix. Ruby style is enforced with [Rubocop](https://github.com/bbatsov/rubocop), run `bundle exec rubocop` and fix any style issues highlighted. -Make sure that `bundle exec rake` completes without errors. +Make sure that `bundle exec rake` and `rubocop` completes without errors. #### Write Documentation diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index 7e727fc..527aa5b 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -436,7 +436,7 @@ def delegate_object private - def respond_to_missing?(method, include_private = false) + def respond_to_missing?(method, include_private = false) # rubocop:disable Style/OptionalBooleanParameter method.to_sym == :method_using_missing || super end From 098fd33fb5c302e432cf8ecc8f5218da11234aea Mon Sep 17 00:00:00 2001 From: Marc Rohloff Date: Wed, 13 May 2026 15:36:02 -0600 Subject: [PATCH 3/4] PR recommendations Add argumenterror specs --- CHANGELOG.md | 3 +- CONTRIBUTING.md | 2 +- lib/grape_entity/entity.rb | 10 +- spec/grape_entity/entity_spec.rb | 201 +++++++++++++++++++++++++++---- 4 files changed, 183 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6568d3d..c6f13cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,8 @@ ### Next Release -* [#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` - [@marcrohloff](https://github.com/marcrohloff). - #### Features +* [#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` - [@marcrohloff](https://github.com/marcrohloff). * Your contribution here. #### Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 655e94e..6023a84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ Implement your feature or bug fix. Ruby style is enforced with [Rubocop](https://github.com/bbatsov/rubocop), run `bundle exec rubocop` and fix any style issues highlighted. -Make sure that `bundle exec rake` and `rubocop` completes without errors. +Make sure that `bundle exec rake` completes without errors. #### Write Documentation diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 1ca44ef..e3051bd 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -293,7 +293,7 @@ def self.documentation end # This allows you to declare a Proc in which exposures can be formatted with. - # It take a block with an arity of 1 which is passed as the value of the exposed attribute. + # It takes a block with a single argument which is passed as the value of the exposed attribute. # # @param name [Symbol] the name of the formatter # @param block [Proc] the block that will interpret the exposed attribute @@ -547,12 +547,14 @@ def ensure_block_arity!(block) MSG end + # Ensure that the function does not require any positional args + # (functions defined using `delegate` or `method_missing` take an arg of `*rest` arity = object.method(origin_method_name).arity - # functions defined using `delegate` or `method_missing` have an arity of -1 - return if arity <= 0 + required_positional_arg_count = arity >= 0 ? arity : -arity - 1 + return if required_positional_arg_count.zero? raise ArgumentError, <<~MSG - Cannot use `&:#{origin_method_name}` because that method expects #{arity} argument#{'s' if arity != 1}. + Cannot use `&:#{origin_method_name}` because that method expects #{required_positional_arg_count} #{'argument'.pluralize(required_positional_arg_count)}. Symbol‐to‐proc shorthand only works for zero‐argument methods. MSG end diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index 527aa5b..e24c0af 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -400,19 +400,27 @@ class BogusEntity < Grape::Entity describe 'blocks' do class SomeObject - class SomeObjectDelegate - def method_using_delegation - 'delegated-result' - end + def method_without_args + 'result' end - delegate :method_using_delegation, to: :delegate_object + def method_with_one_arg(_object) + 'result' + end - def method_without_args + def method_with_only_optional_args(_optional1 = 1, _optional2 = 2) 'result' end - def method_with_one_arg(_object) + def method_with_required_and_optional_args(_required_arg, _optional1 = 1, _optional2 = 2) + 'result' + end + + def method_with_optional_args_as_a_splat(*_optional_args) + 'result' + end + + def method_with_required_args_and_an_optional_splat(_required_arg, *_optional_argds) 'result' end @@ -423,25 +431,56 @@ def method_with_multiple_args(_object, _options) def raises_argument_error raise ArgumentError, 'something different' end + end + class SomeObjectWithMethodMissing def method_missing(method, ...) - return 'missing-result' if method.to_sym == :method_using_missing + if method.to_sym == :method_without_args + method_without_args_impl(...) + elsif method.to_sym == :method_with_args + method_with_args_impl(...) + else + super + end + end - super + def method_without_args_impl + 'result' end - def delegate_object - @delegate_object ||= SomeObjectDelegate.new + def method_with_args_impl(_required_arg) + 'result' end private def respond_to_missing?(method, include_private = false) # rubocop:disable Style/OptionalBooleanParameter - method.to_sym == :method_using_missing || + method.to_sym == :method_without_args || + method.to_sym == :method_with_args || super end end + class SomeObjectWithDelegation + class InnerDelegate + def method_without_args + 'result' + end + + def method_with_args(_required_arg) + 'result' + end + end + + delegate :method_without_args, + :method_with_args, + to: :delegate_object + + def delegate_object + @delegate_object ||= InnerDelegate.new + end + end + describe 'with block passed in' do specify do subject.expose :that_method_without_args do |object| @@ -484,25 +523,135 @@ def respond_to_missing?(method, include_private = false) # rubocop:disable Style end end - context 'with block passed in via & that uses `missing_method`' do - specify do - subject.expose :using_missing, &:method_using_missing + context 'with block passed in via & that references a method with optional args' do + it 'succeeds if there no required arguments' do + subject.expose :that_method_with_only_optional_args, &:method_with_only_optional_args + subject.expose :method_with_only_optional_args, as: :that_method_with_only_optional_args_again + + object = SomeObject.new + + value = subject.represent(object).value_for(:method_with_only_optional_args) + expect(value).to be_nil + + value = subject.represent(object).value_for(:that_method_with_only_optional_args) + expect(value).to eq('result') + + value = subject.represent(object).value_for(:that_method_with_only_optional_args_again) + expect(value).to eq('result') + end + + it 'raises an `ArgumentError` if there are required arguments' do + subject.expose :that_method_with_required_and_optional_args, &:method_with_required_and_optional_args + subject.expose :method_with_required_and_optional_args, as: :that_method_with_required_and_optional_args_again object = SomeObject.new - expect(object.method(:method_using_missing).arity).to eq(-1) - value = subject.represent(object).value_for(:using_missing) - expect(value).to eq('missing-result') + + expect do + subject.represent(object).value_for(:that_method_with_required_and_optional_args) + end.to raise_error ArgumentError, include('method expects 1 argument.') + + expect do + subject.represent(object).value_for(:that_method_with_required_and_optional_args_again) + end.to raise_error ArgumentError, include('(given 0, expected 1..3)') end end - context 'with block passed in via & that uses `delegate`' do + context 'with block passed in via & that references a method with optional args as a splat' do specify do - subject.expose :using_delegation, &:method_using_delegation + subject.expose :that_method_with_optional_args_as_a_splat, &:method_with_optional_args_as_a_splat + subject.expose :method_with_optional_args_as_a_splat, as: :that_method_with_optional_args_as_a_splat_again + + object = SomeObject.new + + value = subject.represent(object).value_for(:method_with_optional_args_as_a_splat) + expect(value).to be_nil + + value = subject.represent(object).value_for(:that_method_with_optional_args_as_a_splat) + expect(value).to eq('result') + + value = subject.represent(object).value_for(:that_method_with_optional_args_as_a_splat_again) + expect(value).to eq('result') + end + + it 'raises an `ArgumentError` if there are required arguments' do + subject.expose :that_method_with_required_args_and_an_optional_splat, &:method_with_required_args_and_an_optional_splat + subject.expose :method_with_required_args_and_an_optional_splat, as: :that_method_with_required_args_and_an_optional_splat_again object = SomeObject.new - expect(object.method(:method_using_delegation).arity).to eq(-1) - value = subject.represent(object).value_for(:using_delegation) - expect(value).to eq('delegated-result') + + expect do + subject.represent(object).value_for(:that_method_with_required_args_and_an_optional_splat) + end.to raise_error ArgumentError, include('method expects 1 argument.') + + expect do + subject.represent(object).value_for(:that_method_with_required_args_and_an_optional_splat_again) + end.to raise_error ArgumentError, include('(given 0, expected 1+)') + end + end + + context 'with block passed in via & that references a method implemented using `method_missing`' do + specify do + subject.expose :that_method_without_args, &:method_without_args + subject.expose :method_without_args, as: :that_method_without_args_again + + object = SomeObjectWithMethodMissing.new + + value = subject.represent(object).value_for(:method_without_args) + expect(value).to be_nil + + value = subject.represent(object).value_for(:that_method_without_args) + expect(value).to eq('result') + + value = subject.represent(object).value_for(:that_method_without_args_again) + expect(value).to eq('result') + end + + it 'raises an `ArgumentError` if there are required arguments' do + subject.expose :that_method_with_args, &:method_with_args + subject.expose :method_with_args, as: :that_method_with_args_again + + object = SomeObjectWithMethodMissing.new + + expect do + subject.represent(object).value_for(:that_method_with_args) + end.to raise_error ArgumentError, include('(given 0, expected 1)') + + expect do + subject.represent(object).value_for(:that_method_with_args_again) + end.to raise_error ArgumentError, include('(given 0, expected 1)') + end + end + + context 'with block passed in via & that references a method implemented using `delegate`' do + specify do + subject.expose :that_method_without_args, &:method_without_args + subject.expose :method_without_args, as: :that_method_without_args_again + + object = SomeObjectWithDelegation.new + + value = subject.represent(object).value_for(:method_without_args) + expect(value).to be_nil + + value = subject.represent(object).value_for(:that_method_without_args) + expect(value).to eq('result') + + value = subject.represent(object).value_for(:that_method_without_args_again) + expect(value).to eq('result') + end + + it 'raises an `ArgumentError` if there are required arguments' do + subject.expose :that_method_with_args, &:method_with_args + subject.expose :method_with_args, as: :that_method_with_args_again + + object = SomeObjectWithDelegation.new + + expect do + subject.represent(object).value_for(:that_method_with_args) + end.to raise_error ArgumentError, include('(given 0, expected 1)') + + expect do + subject.represent(object).value_for(:that_method_with_args_again) + end.to raise_error ArgumentError, include('(given 0, expected 1)') end end @@ -515,11 +664,11 @@ def respond_to_missing?(method, include_private = false) # rubocop:disable Style expect do subject.represent(object).value_for(:that_method_with_one_arg) - end.to raise_error ArgumentError, match(/method expects 1 argument/) + end.to raise_error ArgumentError, include('method expects 1 argument.') expect do subject.represent(object).value_for(:that_method_with_multple_args) - end.to raise_error ArgumentError, match(/method expects 2 arguments/) + end.to raise_error ArgumentError, include('method expects 2 arguments.') end end @@ -531,7 +680,7 @@ def respond_to_missing?(method, include_private = false) # rubocop:disable Style expect do subject.represent(object).value_for(:that_undefined_method) - end.to raise_error ArgumentError, match(/method is not defined in the object/) + end.to raise_error ArgumentError, include('method is not defined in the object') end end From d8f48092bcedaea99b37985b4d3fbf4795c539d1 Mon Sep 17 00:00:00 2001 From: Marc Rohloff Date: Fri, 15 May 2026 11:13:38 -0600 Subject: [PATCH 4/4] Include PR recommendations --- lib/grape_entity/entity.rb | 2 +- spec/grape_entity/entity_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index e3051bd..ef263c5 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -554,7 +554,7 @@ def ensure_block_arity!(block) return if required_positional_arg_count.zero? raise ArgumentError, <<~MSG - Cannot use `&:#{origin_method_name}` because that method expects #{required_positional_arg_count} #{'argument'.pluralize(required_positional_arg_count)}. + Cannot use `&:#{origin_method_name}` because that method expects #{required_positional_arg_count} argument#{'s' if required_positional_arg_count != 1}. Symbol‐to‐proc shorthand only works for zero‐argument methods. MSG end diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index e24c0af..b6a884b 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -420,7 +420,7 @@ def method_with_optional_args_as_a_splat(*_optional_args) 'result' end - def method_with_required_args_and_an_optional_splat(_required_arg, *_optional_argds) + def method_with_required_args_and_an_optional_splat(_required_arg, *_optional_args) 'result' end