Skip to content

Commit 4845948

Browse files
Call a global #on_failure hook when the flow fails
Most of the time, individual operations are responsible for handling their own failures. However, there are cases when it makes sense to handle failures globally, for example, when you want to log the error for a given flow. When using the raw behavior, i.e., when we don't prepend around methods, that can be easily handled manually: ```ruby class CreateUser < Dry::Operation skip_prepending def call(input) steps do attrs = step validate(input) step persist(attrs) user end.tap do |result| log_failure(result.failure) if result.failure? end end # ... end ``` However, by automatically wrapping around `#steps` we gain focus on the happy path, but we lose the ability to handle global failures. Because of that, we introduce an `#on_failure` hook that is only called when using the prepended behavior on a failing flow. The method accepts the unwrapped failure extracted from the result object. ```ruby class CreateUser < Dry::Operation def call(input) attrs = step validate(input) step persist(attrs) user end private def on_failure(failure) log_failure(failure) end # ... end ``` `#on_failure` can also take a second optional argument, which will be assigned to the prepended method's name. That's useful when we're defining more than one flow in a single class. ```ruby class UserFlows < Dry::Operation operate_on :create_user, :delete_user # ... private def on_failure(failure, flow_name) case flow_name when :create_user # ... when :delete_user # ... end end # ... end ``` The calling of the hook is done via an injected result handler lambda. At some point, we might want to make this behavior configurable.
1 parent eac1bd5 commit 4845948

File tree

4 files changed

+227
-7
lines changed

4 files changed

+227
-7
lines changed

lib/dry/operation.rb

+28-3
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ module Dry
5353
#
5454
# Under the hood, the `#call` method is decorated to allow skipping the rest
5555
# of its execution when a failure is encountered. You can choose to use another
56-
# method with {ClassContext#operate_on}:
56+
# method with {ClassContext#operate_on} (which also accepts a list of methods):
5757
#
5858
# ```ruby
5959
# class MyOperation < Dry::Operation
60-
# operate_on :run
60+
# operate_on :run # or operate_on :run, :call
6161
#
6262
# def run(input)
6363
# attrs = step validate(input)
@@ -70,8 +70,31 @@ module Dry
7070
# end
7171
# ```
7272
#
73+
# As you can see, the aforementioned behavior allows you to write your flow
74+
# in a linear fashion. Failures are mostly handled locally by each individual
75+
# operation. However, you can also define a global failure handler by defining
76+
# an `#on_failure` method. It will be called with the wrapped failure value
77+
# and, in the case of accepting a second argument, the name of the method that
78+
# defined the flow:
79+
#
80+
# ```ruby
81+
# class MyOperation < Dry::Operation
82+
# def call(input)
83+
# attrs = step validate(input)
84+
# user = step persist(attrs)
85+
# step notify(user)
86+
# user
87+
# end
88+
#
89+
# def on_failure(user) # or def on_failure(value, method_name)
90+
# log_failure(user)
91+
# end
92+
# end
93+
# ```
94+
#
7395
# You can opt out altogether of this behavior via {ClassContext#skip_prepending}. If so,
74-
# you manually need to wrap your flow within the {#steps} method.
96+
# you manually need to wrap your flow within the {#steps} method and manually
97+
# handle global failures.
7598
#
7699
# ```ruby
77100
# class MyOperation < Dry::Operation
@@ -83,6 +106,8 @@ module Dry
83106
# user = step persist(attrs)
84107
# step notify(user)
85108
# user
109+
# end.tap do |result|
110+
# log_failure(result.failure) if result.failure?
86111
# end
87112
# end
88113
#

lib/dry/operation/class_context/steps_method_prepender.rb

+30-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
11
# frozen_string_literal: true
22

3+
require "dry/operation/errors"
4+
35
module Dry
46
class Operation
57
module ClassContext
68
# @api private
79
class StepsMethodPrepender < Module
8-
def initialize(method:)
10+
FAILURE_HOOK_METHOD_NAME = :on_failure
11+
12+
RESULT_HANDLER = lambda do |instance, method, result|
13+
return if result.success? ||
14+
!(instance.methods + instance.private_methods).include?(
15+
FAILURE_HOOK_METHOD_NAME
16+
)
17+
18+
failure_hook = instance.method(FAILURE_HOOK_METHOD_NAME)
19+
case failure_hook.arity
20+
when 1
21+
failure_hook.(result.failure)
22+
when 2
23+
failure_hook.(result.failure, method)
24+
else
25+
raise FailureHookArityError.new(hook: failure_hook)
26+
end
27+
end
28+
29+
def initialize(method:, result_handler: RESULT_HANDLER)
930
super()
1031
@method = method
32+
@result_handler = result_handler
1133
end
1234

1335
def included(klass)
@@ -18,9 +40,13 @@ def included(klass)
1840

1941
def mod
2042
@module ||= Module.new.tap do |mod|
21-
mod.define_method(@method) do |*args, **kwargs, &block|
22-
steps do
23-
super(*args, **kwargs, &block)
43+
module_exec(@result_handler) do |result_handler|
44+
mod.define_method(@method) do |*args, **kwargs, &block|
45+
steps do
46+
super(*args, **kwargs, &block)
47+
end.tap do |result|
48+
result_handler.(self, __method__, result)
49+
end
2450
end
2551
end
2652
end

lib/dry/operation/errors.rb

+10
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,15 @@ def initialize(gem:, extension:)
3535

3636
# An error related to an extension
3737
class ExtensionError < ::StandardError; end
38+
39+
# Defined failure hook has wrong arity
40+
class FailureHookArityError < ::StandardError
41+
def initialize(hook:)
42+
super <<~MSG
43+
##{hook.name} must accept 1 (failure) or 2 (failure, method name) \
44+
arguments, but its arity is #{hook.arity}
45+
MSG
46+
end
47+
end
3848
end
3949
end

spec/integration/operations_spec.rb

+159
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,165 @@ def add_one(x) = Success(x + 1)
7070
).to eq(Success(2))
7171
end
7272

73+
context "#on_failure" do
74+
it "is called when prepending if a failure is returned" do
75+
klass = Class.new(Dry::Operation) do
76+
attr_reader :failure
77+
78+
def initialize
79+
super
80+
@failure = nil
81+
end
82+
83+
def call(x)
84+
step divide_by_zero(x)
85+
end
86+
87+
def divide_by_zero(_x) = Failure(:not_possible)
88+
89+
def on_failure(failure)
90+
@failure = failure
91+
end
92+
end
93+
instance = klass.new
94+
95+
instance.(1)
96+
97+
expect(
98+
instance.failure
99+
).to be(:not_possible)
100+
end
101+
102+
it "isn't called if a success is returned" do
103+
klass = Class.new(Dry::Operation) do
104+
attr_reader :failure
105+
106+
def initialize
107+
super
108+
@failure = nil
109+
end
110+
111+
def call(x)
112+
step add_one(x)
113+
end
114+
115+
def add_one(x) = Success(x + 1)
116+
117+
def on_failure(failure)
118+
@failure = failure
119+
end
120+
end
121+
instance = klass.new
122+
123+
instance.(1)
124+
125+
expect(
126+
instance.failure
127+
).to be(nil)
128+
end
129+
130+
it "is given the prepended method name when it accepts a second argument" do
131+
klass = Class.new(Dry::Operation) do
132+
attr_reader :method_name
133+
134+
def initialize
135+
super
136+
@method_name = nil
137+
end
138+
139+
def call(x)
140+
step divide_by_zero(x)
141+
end
142+
143+
def divide_by_zero(_x) = Failure(:not_possible)
144+
145+
def on_failure(_failure, method_name)
146+
@method_name = method_name
147+
end
148+
end
149+
instance = klass.new
150+
151+
instance.(1)
152+
153+
expect(
154+
instance.method_name
155+
).to be(:call)
156+
end
157+
158+
it "has its arity checked and a meaningful error is raised when not conforming" do
159+
klass = Class.new(Dry::Operation) do
160+
def call(x)
161+
step divide_by_zero(x)
162+
end
163+
164+
def divide_by_zero(_x) = Failure(:not_possible)
165+
166+
def on_failure(_failure, _method_name, _unknown); end
167+
end
168+
169+
expect { klass.new.(1) }.to raise_error(Dry::Operation::FailureHookArityError, /arity is 3/)
170+
end
171+
172+
it "can be defined in a parent class" do
173+
klass = Class.new(Dry::Operation) do
174+
attr_reader :failure
175+
176+
def initialize
177+
super
178+
@failure = nil
179+
end
180+
181+
def on_failure(failure)
182+
@failure = failure
183+
end
184+
end
185+
qlass = Class.new(klass) do
186+
def call(x)
187+
step divide_by_zero(x)
188+
end
189+
190+
def divide_by_zero(_x) = Failure(:not_possible)
191+
end
192+
instance = qlass.new
193+
194+
instance.(1)
195+
196+
expect(
197+
instance.failure
198+
).to be(:not_possible)
199+
end
200+
201+
it "can be a private method" do
202+
klass = Class.new(Dry::Operation) do
203+
attr_reader :failure
204+
205+
def initialize
206+
super
207+
@failure = nil
208+
end
209+
210+
def call(x)
211+
step divide_by_zero(x)
212+
end
213+
214+
def divide_by_zero(_x) = Failure(:not_possible)
215+
216+
private
217+
218+
def on_failure(failure)
219+
@failure = failure
220+
end
221+
end
222+
instance = klass.new
223+
224+
instance.(1)
225+
226+
expect(
227+
instance.failure
228+
).to be(:not_possible)
229+
end
230+
end
231+
73232
context ".operate_on" do
74233
it "allows prepending around a method other than #call" do
75234
klass = Class.new(Dry::Operation) do

0 commit comments

Comments
 (0)