hanami-utils/lib/hanami/interactor.rb

498 lines
11 KiB
Ruby

require 'hanami/utils/basic_object'
require 'hanami/utils/class_attribute'
require 'hanami/utils/hash'
module Hanami
# Hanami Interactor
#
# @since 0.3.5
module Interactor
# Result of an operation
#
# @since 0.3.5
class Result < Utils::BasicObject
# Concrete methods
#
# @since 0.3.5
# @api private
#
# @see Hanami::Interactor::Result#respond_to_missing?
METHODS = {initialize: true, success?: true, fail!: true, prepare!: true, errors: true, error: true}.freeze
# Initialize a new result
#
# @param payload [Hash] a payload to carry on
#
# @return [Hanami::Interactor::Result]
#
# @since 0.3.5
# @api private
def initialize(payload = {})
@payload = _payload(payload)
@errors = []
@success = true
end
# Check if the current status is successful
#
# @return [TrueClass,FalseClass] the result of the check
#
# @since 0.3.5
def success?
@success && errors.empty?
end
# Force the status to be a failure
#
# @since 0.3.5
def fail!
@success = false
end
# Returns all the errors collected during an operation
#
# @return [Array] the errors
#
# @since 0.3.5
#
# @see Hanami::Interactor::Result#error
# @see Hanami::Interactor#call
# @see Hanami::Interactor#error
# @see Hanami::Interactor#error!
def errors
@errors.dup
end
# @since 0.5.0
# @api private
def add_error(*errors)
@errors << errors
@errors.flatten!
nil
end
# Returns the first errors collected during an operation
#
# @return [nil,String] the error, if present
#
# @since 0.3.5
#
# @see Hanami::Interactor::Result#errors
# @see Hanami::Interactor#call
# @see Hanami::Interactor#error
# @see Hanami::Interactor#error!
def error
errors.first
end
# Prepare the result before to be returned
#
# @param payload [Hash] an updated payload
#
# @since 0.3.5
# @api private
def prepare!(payload)
@payload.merge!(_payload(payload))
self
end
protected
# @since 0.3.5
# @api private
def method_missing(m, *)
@payload.fetch(m) { super }
end
# @since 0.3.5
# @api private
def respond_to_missing?(method_name, include_all)
method_name = method_name.to_sym
METHODS[method_name] || @payload.key?(method_name)
end
# @since 0.3.5
# @api private
def _payload(payload)
Utils::Hash.new(payload).symbolize!
end
# @since 0.3.5
# @api private
def __inspect
" @success=#{ @success } @payload=#{ @payload.inspect }"
end
end
# Override for <tt>Module#included</tt>.
#
# @since 0.3.5
# @api private
def self.included(base)
super
base.class_eval do
prepend Interface
extend ClassMethods
end
end
# Interactor interface
#
# @since 0.3.5
module Interface
# Initialize an interactor
#
# It accepts arbitrary number of arguments.
# Developers can override it.
#
# @param args [Array<Object>] arbitrary number of arguments
#
# @return [Hanami::Interactor] the interactor
#
# @since 0.3.5
#
# @example Override #initialize
# require 'hanami/interactor'
#
# class UpdateProfile
# include Hanami::Interactor
#
# def initialize(user, params)
# @user = user
# @params = params
# end
#
# def call
# # ...
# end
# end
def initialize(*args)
super
ensure
@__result = ::Hanami::Interactor::Result.new
end
# Triggers the operation and return a result.
#
# All the 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 initialize(params)
# @params = params
# @user = User.new(@params)
# @foo = 'bar'
# end
#
# def call
# @user = UserRepository.persist(@user)
# end
# end
#
# result = Signup.new(name: 'Luca').call
# result.success? # => true
#
# result.user # => #<User:0x007fa311105778 @id=1 @name="Luca">
# result.params # => { :name=>"Luca" }
# result.foo # => raises NoMethodError
#
# @example Failed precondition
# require 'hanami/interactor'
#
# class Signup
# include Hanami::Interactor
# expose :user
#
# def initialize(params)
# @params = params
# @user = User.new(@params)
# end
#
# # THIS WON'T BE INVOKED BECAUSE #valid? WILL RETURN false
# def call
# @user = UserRepository.persist(@user)
# end
#
# private
# def valid?
# @params.valid?
# end
# end
#
# result = Signup.new(name: nil).call
# result.success? # => false
#
# result.user # => #<User:0x007fa311105778 @id=nil @name="Luca">
#
# @example Bad usage
# require 'hanami/interactor'
#
# class Signup
# include Hanami::Interactor
#
# # Method #call is not defined
# end
#
# Signup.new.call # => NoMethodError
def call
_call { super }
end
end
private
# Check if proceed with <tt>#call</tt> invokation.
# By default it returns <tt>true</tt>.
#
# Developers can override it.
#
# @return [TrueClass,FalseClass] the result of the check
#
# @since 0.3.5
def valid?
true
end
# Fail and interrupt the current flow.
#
# @since 0.3.5
#
# @example
# require 'hanami/interactor'
#
# class CreateEmailTest
# include Hanami::Interactor
#
# def initialize(params)
# @params = params
# @email_test = EmailTest.new(@params)
# end
#
# def call
# persist_email_test!
# capture_screenshot!
# end
#
# private
# def persist_email_test!
# @email_test = EmailTestRepository.persist(@email_test)
# end
#
# # IF THIS RAISES AN EXCEPTION WE FORCE A FAILURE
# def capture_screenshot!
# Screenshot.new(@email_test).capture!
# rescue
# fail!
# end
# end
#
# result = CreateEmailTest.new(account_id: 1).call
# result.success? # => false
def fail!
@__result.fail!
throw :fail
end
# Log an error without interrupting the flow.
#
# When used, the returned result won't be successful.
#
# @param message [String] the error message
#
# @return false
#
# @since 0.3.5
#
# @see Hanami::Interactor#error!
#
# @example
# require 'hanami/interactor'
#
# class CreateRecord
# include Hanami::Interactor
# expose :logger
#
# def initialize
# @logger = []
# end
#
# def call
# prepare_data!
# persist!
# sync!
# end
#
# private
# def prepare_data!
# @logger << __method__
# error "Prepare data error"
# end
#
# def persist!
# @logger << __method__
# error "Persist error"
# end
#
# def sync!
# @logger << __method__
# end
# end
#
# result = CreateRecord.new.call
# result.success? # => false
#
# result.errors # => ["Prepare data error", "Persist error"]
# result.logger # => [:prepare_data!, :persist!, :sync!]
def error(message)
@__result.add_error message
false
end
# Log an error AND interrupting the flow.
#
# When used, the returned result won't be successful.
#
# @param message [String] the error message
#
# @since 0.3.5
#
# @see Hanami::Interactor#error
#
# @example
# require 'hanami/interactor'
#
# class CreateRecord
# include Hanami::Interactor
# expose :logger
#
# def initialize
# @logger = []
# end
#
# def call
# prepare_data!
# persist!
# sync!
# end
#
# private
# def prepare_data!
# @logger << __method__
# error "Prepare data error"
# end
#
# def persist!
# @logger << __method__
# error! "Persist error"
# end
#
# # THIS WILL NEVER BE INVOKED BECAUSE WE USE #error! IN #persist!
# def sync!
# @logger << __method__
# end
# end
#
# result = CreateRecord.new.call
# result.success? # => false
#
# result.errors # => ["Prepare data error", "Persist error"]
# result.logger # => [:prepare_data!, :persist!]
def error!(message)
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!
@__result.prepare!(_exposures)
end
# @since 0.5.0
# @api private
def _exposures
Hash[].tap do |result|
self.class.exposures.each do |name, ivar|
result[name] = instance_variable_get(ivar)
end
end
end
end
# @since 0.5.0
# @api private
module ClassMethods
# @since 0.5.0
# @api private
def self.extended(interactor)
interactor.class_eval do
include Utils::ClassAttribute
class_attribute :exposures
self.exposures = Utils::Hash.new
end
end
# Expose local instance variables into the returing value of <tt>#call</tt>
#
# @param instance_variable_names [Symbol,Array<Symbol>] one or more instance
# variable names
#
# @since 0.5.0
#
# @see Hanami::Interactor::Result
#
# @example Expose instance variable
#
# class Signup
# include Hanami::Interactor
# expose :user
#
# def initialize(params)
# @params = params
# @user = User.new(@params[:user])
# end
#
# def call
# # ...
# end
# end
#
# result = Signup.new(user: { name: "Luca" }).call
#
# result.user # => #<User:0x007fa85c58ccd8 @name="Luca">
# result.params # => NoMethodError
def expose(*instance_variable_names)
instance_variable_names.each do |name|
exposures[name] = "@#{ name }"
end
end
end
end