diff --git a/interactor.gemspec b/interactor.gemspec index ca108d4..0cb25dc 100644 --- a/interactor.gemspec +++ b/interactor.gemspec @@ -1,17 +1,17 @@ require "English" Gem::Specification.new do |spec| - spec.name = "interactor" + spec.name = "interactor" spec.version = "3.1.1" - spec.author = "Collective Idea" - spec.email = "info@collectiveidea.com" + spec.author = "Collective Idea" + spec.email = "info@collectiveidea.com" spec.description = "Interactor provides a common interface for performing complex user interactions." - spec.summary = "Simple interactor implementation" - spec.homepage = "https://github.com/collectiveidea/interactor" - spec.license = "MIT" + spec.summary = "Simple interactor implementation" + spec.homepage = "https://github.com/collectiveidea/interactor" + spec.license = "MIT" - spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) + spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) spec.test_files = spec.files.grep(/^spec/) spec.add_development_dependency "bundler" diff --git a/lib/interactor.rb b/lib/interactor.rb index 2423630..4998f77 100644 --- a/lib/interactor.rb +++ b/lib/interactor.rb @@ -22,6 +22,12 @@ def self.included(base) extend ClassMethods include Hooks + # Internal: Expose instance variables of Interactor instance metaclass + # for reading. + class << self + attr_reader :exception_classes + attr_reader :exception_handlers + end # Public: Gets the Interactor::Context of the Interactor instance. attr_reader :context end @@ -29,6 +35,59 @@ def self.included(base) # Internal: Interactor class methods. module ClassMethods + # Public: Specify exception classes that should result in failing context + # and provide a custom logic on rescued exception before failing. Failing + # the context is raising Interactor::Failure, this exception is silently + # swallowed by the interactor. Note that any code after failing the context + # will not be evaluated. + # + # Examples + # class MyInteractor + # include Interactor + # + # fail_on_exception StandardErro + # fail_on_exception NameError, NoMethodError + # + # exception_handler = ->(e) { ErrorLogger.log(e) } + # + # fail_on_exception MyBespokeError, exception_handler: exception_handler + # + # def call + # exception_raising_logic + # end + # end + # + # MyInteractor.call + # # => # Did you mean? method_missing>> + # + # MyInteractor.call.success? + # # => false + # + # Returned context holds the rescued exception object + # + # MyInteractor.call.error.class.name + # # => "NameError" + # + # Method accepts object representing exception classes of any type that + # will respond to #to_s and return string, as an argument to + # Kernel.const_get will result in previously initialized constant. + # e.g. constant, symbol, string... + + def fail_on_exception(*exceptions_to_fail_on, exception_handler: ->(e) {}) + exceptions_to_fail_on = exceptions_to_fail_on.each do |it| + Kernel.const_get(it.to_s) + end + @exception_classes = Array(exception_classes) | exceptions_to_fail_on + return unless exception_handler + exceptions_to_fail_on.each do |exception_class| + @exception_handlers = Hash(exception_handlers).update( + exception_class.name.to_sym => exception_handler + ) + end + end + # Public: Invoke an Interactor. This is the primary public API method to an # interactor. # @@ -140,8 +199,13 @@ def run # Raises Interactor::Failure if the context is failed. def run! with_hooks do - call - context.called!(self) + begin + call + context.called!(self) + rescue *self.class.exception_classes => e + self.class.exception_handlers[e.class.name.to_sym]&.call(e) + context.fail!(error: e) + end end rescue context.rollback!