diff --git a/lib/hanami/interactor.rb b/lib/hanami/interactor.rb index 6e63c5b4..db96b0bf 100644 --- a/lib/hanami/interactor.rb +++ b/lib/hanami/interactor.rb @@ -145,15 +145,14 @@ def self.included(base) super base.class_eval do - prepend Interface - extend ClassMethods + extend ClassMethods end end - # Interactor interface + # Interactor legacy interface # # @since 0.3.5 - module Interface + module LegacyInterface # Initialize an interactor # # It accepts arbitrary number of arguments. @@ -262,6 +261,118 @@ def initialize(*args) def call _call { super } end + + private + + # @since 0.3.5 + # @api private + def _call + catch :fail do + validate! + yield + end + + _prepare! + end + + # @since 0.3.5 + def validate! + fail! unless valid? + end + end + + # Interactor interface + # @since x.x.x + module Interface + # Triggers the operation and return a result. + # + # All the exposed instance variables will be available in the result. + # + # ATTENTION: This must be implemented by the including class. + # + # @return [Hanami::Interactor::Result] the result of the operation + # + # @raise [NoMethodError] if this isn't implemented by the including class. + # + # @example Expose instance variables in result payload + # require 'hanami/interactor' + # + # class Signup + # include Hanami::Interactor + # expose :user, :params + # + # def call(params) + # @params = params + # @foo = 'bar' + # @user = UserRepository.new.persist(User.new(params)) + # end + # end + # + # result = Signup.new(name: 'Luca').call + # result.failure? # => false + # result.successful? # => true + # + # result.user # => # + # result.params # => { :name=>"Luca" } + # result.foo # => raises NoMethodError + # + # @example Failed precondition + # require 'hanami/interactor' + # + # class Signup + # include Hanami::Interactor + # expose :user + # + # # THIS WON'T BE INVOKED BECAUSE #valid? WILL RETURN false + # def call(params) + # @user = User.new(params) + # @user = UserRepository.new.persist(@user) + # end + # + # private + # def valid?(params) + # params.valid? + # end + # end + # + # result = Signup.new.call(name: nil) + # result.successful? # => false + # result.failure? # => true + # + # result.user # => nil + # + # @example Bad usage + # require 'hanami/interactor' + # + # class Signup + # include Hanami::Interactor + # + # # Method #call is not defined + # end + # + # Signup.new.call # => NoMethodError + def call(*args, **kwargs) + @__result = ::Hanami::Interactor::Result.new + _call(*args, **kwargs) { super } + end + + private + + # @api private + # @since x.x.x + def _call(*args, **kwargs) + catch :fail do + validate!(*args, **kwargs) + yield + end + + _prepare! + end + + # @since x.x.x + def validate!(*args, **kwargs) + fail! unless valid?(*args, **kwargs) + end end private @@ -274,7 +385,7 @@ def call # @return [TrueClass,FalseClass] the result of the check # # @since 0.3.5 - def valid? + def valid?(*) true end @@ -426,22 +537,6 @@ def error!(message) fail! end - # @since 0.3.5 - # @api private - def _call - catch :fail do - validate! - yield - end - - _prepare! - end - - # @since 0.3.5 - def validate! - fail! unless valid? - end - # @since 0.3.5 # @api private def _prepare! @@ -453,7 +548,7 @@ def _prepare! def _exposures Hash[].tap do |result| self.class.exposures.each do |name, ivar| - result[name] = instance_variable_get(ivar) + result[name] = instance_variable_defined?(ivar) ? instance_variable_get(ivar) : nil end end end @@ -473,6 +568,17 @@ def self.extended(interactor) end end + def method_added(method_name) + super + return unless method_name == :call + + if instance_method(:call).arity.zero? + prepend Hanami::Interactor::LegacyInterface + else + prepend Hanami::Interactor::Interface + end + end + # Expose local instance variables into the returning value of #call # # @param instance_variable_names [Symbol,Array] one or more instance diff --git a/spec/unit/hanami/interactor_spec.rb b/spec/unit/hanami/interactor_spec.rb index b3fd4c20..8d884bef 100644 --- a/spec/unit/hanami/interactor_spec.rb +++ b/spec/unit/hanami/interactor_spec.rb @@ -1,16 +1,40 @@ require 'hanami/interactor' -class InteractorWithoutInitialize +class LegacyInteractorWithoutInitialize include Hanami::Interactor def call end end +class InteractorWithoutInitialize + include Hanami::Interactor + + def call(*) + end +end + class InteractorWithoutCall include Hanami::Interactor end +class InteractorWithMethodAdded + module MethodAdded; end + + module WatchMethods + def method_added(method_name) + super + include MethodAdded if method_name == :call + end + end + + include Hanami::Interactor + extend WatchMethods + + def call(*) + end +end + class User def initialize(attributes = {}) @attributes = attributes @@ -33,7 +57,7 @@ def to_hash end end -class Signup +class LegacySignup include Hanami::Interactor expose :user, :params @@ -56,7 +80,40 @@ def valid? end end -class ErrorInteractor +class Signup + include Hanami::Interactor + expose :user, :params + + def initialize(force_failure: false) + @force_failure = force_failure + end + + def call(params) + @params = params + @user = User.new(params) + @user.persist! + rescue + fail! + end + + private + + def valid?(*) + !@force_failure + end +end + +class ComplexCall + include Hanami::Interactor + expose :args, :kwargs + + def call(*args, **kwargs) + @args = args + @kwargs = kwargs + end +end + +class LegacyErrorInteractor include Hanami::Interactor expose :operations @@ -87,7 +144,35 @@ def log! end end -class ErrorBangInteractor +class ErrorInteractor + include Hanami::Interactor + expose :operations + + def call(*) + @operations = [] + prepare! + persist! + log! + end + + private + + def prepare! + @operations << __method__ + error 'There was an error while preparing data.' + end + + def persist! + @operations << __method__ + error 'There was an error while persisting data.' + end + + def log! + @operations << __method__ + end +end + +class LegacyErrorBangInteractor include Hanami::Interactor expose :operations @@ -113,7 +198,30 @@ def sync! end end -class PublishVideo +class ErrorBangInteractor + include Hanami::Interactor + expose :operations + + def call(*) + @operations = [] + persist! + sync! + end + + private + + def persist! + @operations << __method__ + error! 'There was an error while persisting data.' + end + + def sync! + @operations << __method__ + error 'There was an error while syncing data.' + end +end + +class LegacyPublishVideo include Hanami::Interactor def call @@ -127,12 +235,31 @@ def valid? def owns? # fake failed ownership check - 1 == 0 || - error("You're not owner of this video") + error("You're not owner of this video") end end -class CreateUser +class PublishVideo + include Hanami::Interactor + expose :video_name + + def call(*) + @video_name = 'H2G2' + end + + def valid?(*) + owns? + end + + private + + def owns? + # fake failed ownership check + error("You're not owner of this video") + end +end + +class LegacyCreateUser include Hanami::Interactor expose :user @@ -151,122 +278,287 @@ def persist end end -class UpdateUser < CreateUser +class CreateUser + include Hanami::Interactor + expose :user + + def call(**params) + build_user(params) + persist + end + + private + + def build_user(params) + @user = User.new(params) + end + + def persist + @user.persist! + end +end + +class LegacyUpdateUser < LegacyCreateUser def initialize(_user, params) super(params) @user.name = params.fetch(:name) end end +class UpdateUser < CreateUser + def build_user(user:, **params) + @user = user + @user.name = params.fetch(:name) + end +end + RSpec.describe Hanami::Interactor do - describe '#initialize' do - it "works when it isn't overridden" do - InteractorWithoutInitialize.new + describe 'interactor interface' do + it 'includes the correct interface' do + expect(LegacySignup.ancestors).to include(Hanami::Interactor::LegacyInterface) + expect(Signup.ancestors).to include(Hanami::Interactor::Interface) end - it 'allows to override it' do - Signup.new({}) + it 'does not include the other interface' do + expect(LegacySignup.ancestors).not_to include(Hanami::Interactor::Interface) + expect(Signup.ancestors).not_to include(Hanami::Interactor::LegacyInterface) end - end - describe '#call' do - it 'returns a result' do - result = Signup.new(name: 'Luca').call - expect(result.class).to eq Hanami::Interactor::Result + it "raises error when #call isn't implemented" do + expect { InteractorWithoutCall.new.call }.to raise_error NoMethodError end - it 'is successful by default' do - result = Signup.new(name: 'Luca').call - expect(result).to be_successful + it 'lets .method_added open to overrides' do + expect(InteractorWithMethodAdded.ancestors).to include(InteractorWithMethodAdded::MethodAdded) end + end - it 'returns the payload' do - result = Signup.new(name: 'Luca').call + describe 'legacy interface' do + describe '#initialize' do + it "works when it isn't overridden" do + LegacyInteractorWithoutInitialize.new + end - expect(result.user.name).to eq 'Luca' - expect(result.params).to eq(name: 'Luca') + it 'allows to override it' do + LegacySignup.new({}) + end end - it "doesn't include private ivars" do - result = Signup.new(name: 'Luca').call + describe '#call' do + it 'returns a result' do + result = LegacySignup.new(name: 'Luca').call + expect(result.class).to eq Hanami::Interactor::Result + end + + it 'is successful by default' do + result = LegacySignup.new(name: 'Luca').call + expect(result).to be_successful + end + + it 'returns the payload' do + result = LegacySignup.new(name: 'Luca').call + + expect(result.user.name).to eq 'Luca' + expect(result.params).to eq(name: 'Luca') + end + + it "doesn't include private ivars" do + result = LegacySignup.new(name: 'Luca').call + + expect { result.__foo }.to raise_error NoMethodError + end + + it 'exposes a convenient API for handling failures' do + result = LegacySignup.new({}).call + expect(result).to be_failure + end + + it "doesn't invoke it if the preconditions are failing" do + result = LegacySignup.new(force_failure: true).call + expect(result).to be_failure + end + + describe 'inheritance' do + it 'is successful for super class' do + result = LegacyCreateUser.new(name: 'L').call + + expect(result).to be_successful + expect(result.user.name).to eq 'L' + end + + it 'is successful for sub class' do + user = User.new(name: 'L') + result = LegacyUpdateUser.new(user, name: 'MG').call - expect { result.__foo }.to raise_error NoMethodError + expect(result).to be_successful + expect(result.user.name).to eq 'MG' + end + end end - it 'exposes a convenient API for handling failures' do - result = Signup.new({}).call - expect(result).to be_failure + describe '#error' do + it "isn't successful" do + result = LegacyErrorInteractor.new.call + expect(result).to be_failure + end + + it 'accumulates errors' do + result = LegacyErrorInteractor.new.call + expect(result.errors).to eq [ + 'There was an error while preparing data.', + 'There was an error while persisting data.' + ] + end + + it "doesn't interrupt the flow" do + result = LegacyErrorInteractor.new.call + expect(result.operations).to eq %i(prepare! persist! log!) + end + + # See https://github.com/hanami/utils/issues/69 + it 'returns false as control flow for caller' do + interactor = LegacyPublishVideo.new + expect(interactor).not_to be_valid + end end - it "doesn't invoke it if the preconditions are failing" do - result = Signup.new(force_failure: true).call - expect(result).to be_failure + describe '#error!' do + it "isn't successful" do + result = LegacyErrorBangInteractor.new.call + expect(result).to be_failure + end + + it 'stops at the first error' do + result = LegacyErrorBangInteractor.new.call + expect(result.errors).to eq [ + 'There was an error while persisting data.' + ] + end + + it 'interrupts the flow' do + result = LegacyErrorBangInteractor.new.call + expect(result.operations).to eq [:persist!] + end end + end - it "raises error when #call isn't implemented" do - expect { InteractorWithoutCall.new.call }.to raise_error NoMethodError + describe 'new interface' do + describe '#initialize' do + it "works when it isn't overridden" do + InteractorWithoutInitialize.new.call + end + + it 'allows to override it' do + Signup.new.call + end end - describe 'inheritance' do - it 'is successful for super class' do - result = CreateUser.new(name: 'L').call + describe '#call' do + it 'returns a result' do + result = Signup.new.call(name: 'Luca') + expect(result.class).to eq Hanami::Interactor::Result + end + it 'is successful by default' do + result = Signup.new.call(name: 'Luca') expect(result).to be_successful - expect(result.user.name).to eq 'L' end - it 'is successful for sub class' do - user = User.new(name: 'L') - result = UpdateUser.new(user, name: 'MG').call + it 'returns the payload' do + result = Signup.new.call(name: 'Luca') - expect(result).to be_successful - expect(result.user.name).to eq 'MG' + expect(result.user.name).to eq 'Luca' + expect(result.params).to eq(name: 'Luca') end - end - end - describe '#error' do - it "isn't successful" do - result = ErrorInteractor.new.call - expect(result).to be_failure - end + it "doesn't include private ivars" do + result = Signup.new.call(name: 'Luca') - it 'accumulates errors' do - result = ErrorInteractor.new.call - expect(result.errors).to eq [ - 'There was an error while preparing data.', - 'There was an error while persisting data.' - ] - end + expect { result.force_failure }.to raise_error NoMethodError + end - it "doesn't interrupt the flow" do - result = ErrorInteractor.new.call - expect(result.operations).to eq %i(prepare! persist! log!) - end + it 'exposes a convenient API for handling failures' do + result = Signup.new.call + expect(result).to be_failure + end - # See https://github.com/hanami/utils/issues/69 - it 'returns false as control flow for caller' do - interactor = PublishVideo.new - expect(interactor).not_to be_valid - end - end + it "doesn't invoke it if the preconditions are failing" do + result = Signup.new(force_failure: true).call + expect(result).to be_failure + end - describe '#error!' do - it "isn't successful" do - result = ErrorBangInteractor.new.call - expect(result).to be_failure + it "raises error when #call isn't implemented" do + expect { InteractorWithoutCall.new.call }.to raise_error NoMethodError + end + + it 'handles args and kwargs' do + result = ComplexCall.new.call('foo', 'bar', baz: 'baz', buzz: 'buzz') + expect(result.args).to eql(%w(foo bar)) + expect(result.kwargs).to eql(Hash[baz: 'baz', buzz: 'buzz']) + end + + describe 'inheritance' do + it 'is successful for super class' do + result = CreateUser.new.call(name: 'L') + + expect(result).to be_successful + expect(result.user.name).to eq 'L' + end + + it 'is successful for sub class' do + user = User.new(name: 'L') + result = UpdateUser.new.call(user: user, name: 'MG') + + expect(result).to be_successful + expect(result.user.name).to eq 'MG' + end + end end - it 'stops at the first error' do - result = ErrorBangInteractor.new.call - expect(result.errors).to eq [ - 'There was an error while persisting data.' - ] + describe '#error' do + it "isn't successful" do + result = ErrorInteractor.new.call + expect(result).to be_failure + end + + it 'accumulates errors' do + result = ErrorInteractor.new.call + expect(result.errors).to eq [ + 'There was an error while preparing data.', + 'There was an error while persisting data.' + ] + end + + it "doesn't interrupt the flow" do + result = ErrorInteractor.new.call + expect(result.operations).to eq %i(prepare! persist! log!) + end + + # See https://github.com/hanami/utils/issues/69 + it 'returns false as control flow for caller' do + result = PublishVideo.new.call + expect(result).not_to be_successful + expect(result.video_name).to be_nil + end end - it 'interrupts the flow' do - result = ErrorBangInteractor.new.call - expect(result.operations).to eq [:persist!] + describe '#error!' do + it "isn't successful" do + result = ErrorBangInteractor.new.call + expect(result).to be_failure + end + + it 'stops at the first error' do + result = ErrorBangInteractor.new.call + expect(result.errors).to eq [ + 'There was an error while persisting data.' + ] + end + + it 'interrupts the flow' do + result = ErrorBangInteractor.new.call + expect(result.operations).to eq [:persist!] + end end end end