diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b796c..c6f13cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ #### 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/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index cd14033..ef263c5 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,11 +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 - return if arity.zero? + 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#{'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 5bdd73e..b6a884b 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -408,6 +408,22 @@ def method_with_one_arg(_object) 'result' end + def method_with_only_optional_args(_optional1 = 1, _optional2 = 2) + 'result' + end + + 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_args) + 'result' + end + def method_with_multiple_args(_object, _options) 'result' end @@ -417,6 +433,54 @@ def raises_argument_error end end + class SomeObjectWithMethodMissing + def method_missing(method, ...) + 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 + + def method_without_args_impl + 'result' + end + + 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_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| @@ -459,6 +523,138 @@ def raises_argument_error end end + 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 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 references a method with optional args as a splat' do + specify do + 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 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 + context 'with block passed in via &' do specify do subject.expose :that_method_with_one_arg, &:method_with_one_arg @@ -468,11 +664,11 @@ def raises_argument_error 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 @@ -484,7 +680,7 @@ def raises_argument_error 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