Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Call a global #on_failure hook when the flow fails #14

Merged
merged 1 commit into from
Jun 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions lib/dry/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ module Dry
#
# Under the hood, the `#call` method is decorated to allow skipping the rest
# of its execution when a failure is encountered. You can choose to use another
# method with {ClassContext#operate_on}:
# method with {ClassContext#operate_on} (which also accepts a list of methods):
#
# ```ruby
# class MyOperation < Dry::Operation
# operate_on :run
# operate_on :run # or operate_on :run, :call
#
# def run(input)
# attrs = step validate(input)
Expand All @@ -70,8 +70,31 @@ module Dry
# end
# ```
#
# As you can see, the aforementioned behavior allows you to write your flow
# in a linear fashion. Failures are mostly handled locally by each individual
# operation. However, you can also define a global failure handler by defining
# an `#on_failure` method. It will be called with the wrapped failure value
# and, in the case of accepting a second argument, the name of the method that
# defined the flow:
#
# ```ruby
# class MyOperation < Dry::Operation
# def call(input)
# attrs = step validate(input)
# user = step persist(attrs)
# step notify(user)
# user
# end
#
# def on_failure(user) # or def on_failure(failure_value, method_name)
# log_failure(user)
# end
# end
# ```
#
# You can opt out altogether of this behavior via {ClassContext#skip_prepending}. If so,
# you manually need to wrap your flow within the {#steps} method.
# you manually need to wrap your flow within the {#steps} method and manually
# handle global failures.
#
# ```ruby
# class MyOperation < Dry::Operation
Expand All @@ -83,6 +106,8 @@ module Dry
# user = step persist(attrs)
# step notify(user)
# user
# end.tap do |result|
# log_failure(result.failure) if result.failure?
# end
# end
#
Expand Down
34 changes: 30 additions & 4 deletions lib/dry/operation/class_context/steps_method_prepender.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
# frozen_string_literal: true

require "dry/operation/errors"

module Dry
class Operation
module ClassContext
# @api private
class StepsMethodPrepender < Module
def initialize(method:)
FAILURE_HOOK_METHOD_NAME = :on_failure

RESULT_HANDLER = lambda do |instance, method, result|
return if result.success? ||
!(instance.methods + instance.private_methods).include?(
FAILURE_HOOK_METHOD_NAME
)

failure_hook = instance.method(FAILURE_HOOK_METHOD_NAME)
case failure_hook.arity
when 1
failure_hook.(result.failure)
when 2
failure_hook.(result.failure, method)
else
raise FailureHookArityError.new(hook: failure_hook)
end
end

def initialize(method:, result_handler: RESULT_HANDLER)
super()
@method = method
@result_handler = result_handler
end

def included(klass)
Expand All @@ -18,9 +40,13 @@ def included(klass)

def mod
@module ||= Module.new.tap do |mod|
mod.define_method(@method) do |*args, **kwargs, &block|
steps do
super(*args, **kwargs, &block)
module_exec(@result_handler) do |result_handler|
mod.define_method(@method) do |*args, **kwargs, &block|
steps do
super(*args, **kwargs, &block)
end.tap do |result|
result_handler.(self, __method__, result)
end
end
end
end
Expand Down
10 changes: 10 additions & 0 deletions lib/dry/operation/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,15 @@ def initialize(gem:, extension:)

# An error related to an extension
class ExtensionError < ::StandardError; end

# Defined failure hook has wrong arity
class FailureHookArityError < ::StandardError
def initialize(hook:)
super <<~MSG
##{hook.name} must accept 1 (failure) or 2 (failure, method name) \
arguments, but its arity is #{hook.arity}
MSG
end
end
end
end
159 changes: 159 additions & 0 deletions spec/integration/operations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,165 @@ def add_one(x) = Success(x + 1)
).to eq(Success(2))
end

context "#on_failure" do
it "is called when prepending if a failure is returned" do
klass = Class.new(Dry::Operation) do
attr_reader :failure

def initialize
super
@failure = nil
end

def call(x)
step divide_by_zero(x)
end

def divide_by_zero(_x) = Failure(:not_possible)

def on_failure(failure)
@failure = failure
end
end
instance = klass.new

instance.(1)

expect(
instance.failure
).to be(:not_possible)
end

it "isn't called if a success is returned" do
klass = Class.new(Dry::Operation) do
attr_reader :failure

def initialize
super
@failure = nil
end

def call(x)
step add_one(x)
end

def add_one(x) = Success(x + 1)

def on_failure(failure)
@failure = failure
end
end
instance = klass.new

instance.(1)

expect(
instance.failure
).to be(nil)
end

it "is given the prepended method name when it accepts a second argument" do
klass = Class.new(Dry::Operation) do
attr_reader :method_name

def initialize
super
@method_name = nil
end

def call(x)
step divide_by_zero(x)
end

def divide_by_zero(_x) = Failure(:not_possible)

def on_failure(_failure, method_name)
@method_name = method_name
end
end
instance = klass.new

instance.(1)

expect(
instance.method_name
).to be(:call)
end

it "has its arity checked and a meaningful error is raised when not conforming" do
klass = Class.new(Dry::Operation) do
def call(x)
step divide_by_zero(x)
end

def divide_by_zero(_x) = Failure(:not_possible)

def on_failure(_failure, _method_name, _unknown); end
end

expect { klass.new.(1) }.to raise_error(Dry::Operation::FailureHookArityError, /arity is 3/)
end

it "can be defined in a parent class" do
klass = Class.new(Dry::Operation) do
attr_reader :failure

def initialize
super
@failure = nil
end

def on_failure(failure)
@failure = failure
end
end
qlass = Class.new(klass) do
def call(x)
step divide_by_zero(x)
end

def divide_by_zero(_x) = Failure(:not_possible)
end
instance = qlass.new

instance.(1)

expect(
instance.failure
).to be(:not_possible)
end

it "can be a private method" do
klass = Class.new(Dry::Operation) do
attr_reader :failure

def initialize
super
@failure = nil
end

def call(x)
step divide_by_zero(x)
end

def divide_by_zero(_x) = Failure(:not_possible)

private

def on_failure(failure)
@failure = failure
end
end
instance = klass.new

instance.(1)

expect(
instance.failure
).to be(:not_possible)
end
end

context ".operate_on" do
it "allows prepending around a method other than #call" do
klass = Class.new(Dry::Operation) do
Expand Down
Loading