From 3361f012e894d4bf9252af8a97bd73871f4c700c Mon Sep 17 00:00:00 2001 From: Aaron Lasseigne Date: Sun, 17 Dec 2017 15:57:01 -0600 Subject: [PATCH] expand the interface filter to include checking for ancestors --- CHANGELOG.md | 2 + README.md | 41 +- lib/active_interaction/errors.rb | 5 + lib/active_interaction/filters/file_filter.rb | 8 +- .../filters/interface_filter.rb | 57 ++- .../filters/interface_filter_spec.rb | 414 +++++++++++++++++- .../integration/interface_interaction_spec.rb | 2 +- 7 files changed, 503 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172c0a74..8d7f8f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - [#392][] - Integer parsing now defaults the base to 10. ([how to upgrade](#integer-parsing-base-now-10)) - The `inputs` method now returns an `ActiveInteraction::Input` instead of a hash. The `ActiveInteraction::Input` still responds to all hash methods. +- The `interface` filter will now look for an ancestor of the value passed + based on the name of the interface or the value passed in the `from` option. ## Added diff --git a/README.md b/README.md index 7ee063a4..a1bf514f 100644 --- a/README.md +++ b/README.md @@ -350,8 +350,45 @@ hash :stuff, ### Interface -Interface filters allow you to specify that an object must respond to a certain -set of methods. This allows you to do duck typing with interactions. +Interface filters allow you to specify an interface that the passed value must +meet in order to pass. The name of the interface is used to look for a constant +inside the ancestor listing for the passed value. This allows for a variety of +checks depending on what's passed. Class instances are checked for an included +module or an inherited ancestor class. Classes are checked for an extended +module or an inherited ancestor class. Modules are checked for an extended +module. + +``` rb +class InterfaceInteraction < ActiveInteraction::Base + interface :exception + + def execute + exception + end +end + +InterfaceInteraction.run!(exception: Exception) +# ActiveInteraction::InvalidInteractionError: Exception is not a valid interface +InterfaceInteraction.run!(exception: NameError) # a subclass of Exception +# => NameError +``` + +You can use `:from` to specify a class or module. This would be the equivalent +of what's above. + +```rb +class InterfaceInteraction < ActiveInteraction::Base + interface :error, + from: Exception + + def execute + error + end +end +``` + +You can also create an anonymous interface on the fly by passing the `methods` +option. ``` rb class InterfaceInteraction < ActiveInteraction::Base diff --git a/lib/active_interaction/errors.rb b/lib/active_interaction/errors.rb index d7e06c7f..c78d6334 100644 --- a/lib/active_interaction/errors.rb +++ b/lib/active_interaction/errors.rb @@ -11,6 +11,11 @@ module ActiveInteraction # rubocop:disable Style/Documentation # @return [Class] InvalidClassError = Class.new(Error) + # Raised if an ancestor name is invalid. + # + # @return [Class] + InvalidAncestorError = Class.new(Error) + # Raised if a converter is invalid. # # @return [Class] diff --git a/lib/active_interaction/filters/file_filter.rb b/lib/active_interaction/filters/file_filter.rb index e9493177..398dd60d 100644 --- a/lib/active_interaction/filters/file_filter.rb +++ b/lib/active_interaction/filters/file_filter.rb @@ -15,7 +15,7 @@ class Base end # @private - class FileFilter < InterfaceFilter + class FileFilter < Filter register :file def database_column_type @@ -24,8 +24,10 @@ def database_column_type private - def methods - [:rewind] + def matches?(object) + object.respond_to?(:rewind) + rescue NoMethodError + false end end end diff --git a/lib/active_interaction/filters/interface_filter.rb b/lib/active_interaction/filters/interface_filter.rb index 864471ae..37b0d26c 100644 --- a/lib/active_interaction/filters/interface_filter.rb +++ b/lib/active_interaction/filters/interface_filter.rb @@ -4,14 +4,21 @@ module ActiveInteraction class Base # @!method self.interface(*attributes, options = {}) # Creates accessors for the attributes and ensures that values passed to - # the attributes implement an interface. + # the attributes implement an interface. An interface can be based on a + # set of methods or the existance of a class or module in the ancestors + # of the passed value. # # @!macro filter_method_params - # @option options [Array] :methods ([]) the methods that + # @option options [Constant, String, Symbol] :from (use the attribute + # name) The class or module representing the interface to check for. + # @option options [Array] :methods ([]) the methods that # objects conforming to this interface should respond to # # @example - # interface :anything + # interface :concern + # @example + # interface :person, + # from: Manageable # @example # interface :serializer, # methods: %i[dump load] @@ -21,14 +28,52 @@ class Base class InterfaceFilter < Filter register :interface + def initialize(name, options = {}, &block) + if options.key?(:methods) && options.key?(:from) + raise InvalidFilterError, + 'method and from options cannot both be passed' + end + + super + end + private + def from + const_name = options.fetch(:from, name).to_s.camelize + Object.const_get(const_name) + rescue NameError + raise InvalidAncestorError, + "constant #{const_name.inspect} does not exist" + end + def matches?(object) - methods.all? { |method| object.respond_to?(method) } + return matches_methods?(object) if options.key?(:methods) + + const = from + if checking_class_inheritance?(object, const) + class_inherits_from?(object, const) + else + singleton_ancestor?(object, const) + end + rescue NoMethodError + false + end + + def matches_methods?(object) + options.fetch(:methods, []).all? { |method| object.respond_to?(method) } + end + + def checking_class_inheritance?(object, from) + object.is_a?(Class) && from.is_a?(Class) + end + + def class_inherits_from?(klass, inherits_from) + klass != inherits_from && klass.ancestors.include?(inherits_from) end - def methods - options.fetch(:methods, []) + def singleton_ancestor?(object, from) + object.class != from && object.singleton_class.ancestors.include?(from) end end end diff --git a/spec/active_interaction/filters/interface_filter_spec.rb b/spec/active_interaction/filters/interface_filter_spec.rb index e798e864..9873e818 100644 --- a/spec/active_interaction/filters/interface_filter_spec.rb +++ b/spec/active_interaction/filters/interface_filter_spec.rb @@ -1,33 +1,419 @@ require 'spec_helper' +module InterfaceModule; end +class InterfaceClass; end + describe ActiveInteraction::InterfaceFilter, :filter do include_context 'filters' - it_behaves_like 'a filter' - - before { options[:methods] = %i[dump load] } + it_behaves_like 'a filter' do + let(:name) { :interface_module } + end describe '#cast' do let(:result) { filter.cast(value, nil) } - context 'with a matching object' do - let(:value) do - Class.new do - def dump; end + context 'with an implicit constant name' do + context 'passed an instance' do + context 'with the module included' do + let(:name) { :interface_module } + let(:value) do + Class.new do + include InterfaceModule + end.new + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'with the class inherited from' do + let(:name) { :interface_class } + let(:value) do + Class.new(InterfaceClass) {}.new + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that is extended by the ancestor' do + let(:name) { :interface_module } + let(:value) do + Class.new {}.new.extend(InterfaceModule) + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that does not match' do + let(:name) { :interface_module } + let(:value) { Class.new } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'with the class itself' do + let(:name) { :interface_class } + let(:value) do + InterfaceClass.new + end + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + end + + context 'passed a class' do + context 'with the class inherited from' do + let(:name) { :interface_class } + let(:value) do + Class.new(InterfaceClass) {} + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that is extended by the ancestor' do + let(:name) { :interface_module } + let(:value) do + Class.new do + extend InterfaceModule + end + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that does not match' do + let(:name) { :interface_class } + let(:value) { Class } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'with the class itself' do + let(:name) { :interface_class } + let(:value) { InterfaceClass } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + end + + context 'passed a module' do + context 'that is extended by the ancestor' do + let(:name) { :interface_module } + let(:value) do + Module.new do + extend InterfaceModule + end + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that does not match' do + let(:name) { :interface_module } + let(:value) { Module.new } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'with the module itself' do + let(:name) { :interface_module } + let(:value) { InterfaceModule } - def load; end - end.new + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end end - it 'returns a the value' do - expect(result).to eql value + context 'given an invalid name' do + let(:name) { :invalid } + let(:value) { Object } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidAncestorError + end end end - context 'with an non-matching object' do - let(:value) { Object.new } + context 'with a constant given' do + context 'passed an instance' do + context 'with the module included' do + before { options.merge!(from: :interface_module) } + let(:value) do + Class.new do + include InterfaceModule + end.new + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'with the class inherited from' do + before { options.merge!(from: :interface_class) } + let(:value) do + Class.new(InterfaceClass) {}.new + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that is extended by the ancestor' do + before { options.merge!(from: :interface_module) } + let(:value) do + Class.new {}.new.extend(InterfaceModule) + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that does not match' do + let(:name) { :interface_class } + let(:value) { Class.new } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'with the class itself' do + let(:name) { :interface_class } + let(:value) { InterfaceClass.new } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + end + + context 'passed a class' do + context 'with the class inherited from' do + before { options.merge!(from: :interface_class) } + let(:value) do + Class.new(InterfaceClass) {} + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that is extended by the ancestor' do + before { options.merge!(from: :interface_module) } + let(:value) do + Class.new do + extend InterfaceModule + end + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that does not match' do + let(:name) { :interface_class } + let(:value) { Class } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'with the class itself' do + let(:name) { :interface_class } + let(:value) { InterfaceClass } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + end + + context 'passed a module' do + context 'that is extended by the ancestor' do + before { options.merge!(from: :interface_module) } + let(:value) do + Module.new do + extend InterfaceModule + end + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'that does not match' do + let(:name) { :interface_module } + let(:value) { Module.new } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'with the module itself' do + let(:name) { :interface_module } + let(:value) { InterfaceModule } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + end + + context 'given an invalid name' do + before { options.merge!(from: :invalid) } + let(:value) { Object } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidAncestorError + end + end + end + + context 'with methods passed' do + before { options[:methods] = %i[dump load] } + + context 'passed an valid instance' do + let(:value) do + Class.new do + def dump; end + + def load; end + end.new + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'passed an invalid instance' do + let(:value) { Class.new } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'passed a class' do + let(:value) do + Class.new do + def self.dump; end + + def self.load; end + end + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'passed an invalid class' do + let(:value) { Class } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + + context 'passed a module' do + let(:value) do + Module.new do + def self.dump; end + + def self.load; end + end + end + + it 'returns a the value' do + expect(result).to eql value + end + end + + context 'passed an invalid module' do + let(:value) { Module.new } + + it 'raises an error' do + expect do + result + end.to raise_error ActiveInteraction::InvalidValueError + end + end + end + + context 'with from and methods passed' do + before do + options[:from] = :module + options[:methods] = %i[dump load] + end it 'raises an error' do - expect { result }.to raise_error ActiveInteraction::InvalidValueError + expect do + filter + end.to raise_error ActiveInteraction::InvalidFilterError end end end diff --git a/spec/active_interaction/integration/interface_interaction_spec.rb b/spec/active_interaction/integration/interface_interaction_spec.rb index ad85631e..7c526918 100644 --- a/spec/active_interaction/integration/interface_interaction_spec.rb +++ b/spec/active_interaction/integration/interface_interaction_spec.rb @@ -3,7 +3,7 @@ require 'yaml' InterfaceInteraction = Class.new(TestInteraction) do - interface :anything + interface :anything, methods: [] end describe InterfaceInteraction do