easyop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/README.md +1089 -0
- data/lib/easyop/configuration.rb +31 -0
- data/lib/easyop/ctx.rb +187 -0
- data/lib/easyop/flow.rb +94 -0
- data/lib/easyop/flow_builder.rb +80 -0
- data/lib/easyop/hooks.rb +108 -0
- data/lib/easyop/operation.rb +115 -0
- data/lib/easyop/plugins/async.rb +98 -0
- data/lib/easyop/plugins/base.rb +27 -0
- data/lib/easyop/plugins/instrumentation.rb +74 -0
- data/lib/easyop/plugins/recording.rb +115 -0
- data/lib/easyop/plugins/transactional.rb +69 -0
- data/lib/easyop/rescuable.rb +68 -0
- data/lib/easyop/schema.rb +168 -0
- data/lib/easyop/skip.rb +22 -0
- data/lib/easyop/version.rb +3 -0
- data/lib/easyop.rb +41 -0
- metadata +94 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
class Configuration
|
|
3
|
+
# Which type adapter to use for Schema validation.
|
|
4
|
+
# Options: :none, :native, :literal, :dry, :active_model
|
|
5
|
+
attr_accessor :type_adapter
|
|
6
|
+
|
|
7
|
+
# When true, type mismatches in schemas raise Ctx::Failure.
|
|
8
|
+
# When false (default), mismatches emit a warning and execution continues.
|
|
9
|
+
attr_accessor :strict_types
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@type_adapter = :native
|
|
13
|
+
@strict_types = false
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def config
|
|
19
|
+
@config ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configure
|
|
23
|
+
yield config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Reset config (useful in tests)
|
|
27
|
+
def reset_config!
|
|
28
|
+
@config = Configuration.new
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/easyop/ctx.rb
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
# Easyop::Ctx is the shared data bag passed through an operation (or flow of
|
|
3
|
+
# operations). It replaces Interactor's Context with a faster, Hash-backed
|
|
4
|
+
# implementation that avoids the deprecated OpenStruct.
|
|
5
|
+
#
|
|
6
|
+
# It doubles as the result object returned from Operation.call — the caller
|
|
7
|
+
# inspects ctx.success? / ctx.failure? and reads output attributes directly.
|
|
8
|
+
#
|
|
9
|
+
# Key API:
|
|
10
|
+
# ctx.fail! # mark failed (raises Ctx::Failure internally)
|
|
11
|
+
# ctx.fail!(error: "Boom!") # set attrs AND fail
|
|
12
|
+
# ctx.success? / ctx.ok? # true unless fail! was called
|
|
13
|
+
# ctx.failure? / ctx.failed? # true after fail!
|
|
14
|
+
# ctx.error # shortcut for ctx[:error]
|
|
15
|
+
# ctx.errors # shortcut for ctx[:errors] ({} by default)
|
|
16
|
+
# ctx[:key] / ctx.key # attribute read
|
|
17
|
+
# ctx[:key] = v / ctx.key = v # attribute write
|
|
18
|
+
# ctx.merge!(hash) # bulk-set attributes
|
|
19
|
+
# ctx.on_success { |ctx| ... } # chainable callback
|
|
20
|
+
# ctx.on_failure { |ctx| ... } # chainable callback
|
|
21
|
+
class Ctx
|
|
22
|
+
# Raised (and swallowed by Operation#run) when fail! is called.
|
|
23
|
+
# Propagates to callers of Operation#run! and Operation#call!.
|
|
24
|
+
class Failure < StandardError
|
|
25
|
+
attr_reader :ctx
|
|
26
|
+
|
|
27
|
+
def initialize(ctx)
|
|
28
|
+
@ctx = ctx
|
|
29
|
+
super("Operation failed#{": #{ctx.error}" if ctx.error}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ── Construction ────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def self.build(attrs = {})
|
|
36
|
+
return attrs if attrs.is_a?(self)
|
|
37
|
+
new(attrs)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(attrs = {})
|
|
41
|
+
@attributes = {}
|
|
42
|
+
@failure = false
|
|
43
|
+
@rolled_back = false
|
|
44
|
+
@called = [] # interactors already run (for rollback)
|
|
45
|
+
attrs.each { |k, v| self[k] = v }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ── Attribute access ─────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def [](key)
|
|
51
|
+
@attributes[key.to_sym]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def []=(key, val)
|
|
55
|
+
@attributes[key.to_sym] = val
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def merge!(attrs = {})
|
|
59
|
+
attrs.each { |k, v| self[k] = v }
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_h
|
|
64
|
+
@attributes.dup
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def key?(key)
|
|
68
|
+
@attributes.key?(key.to_sym)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns a plain Hash with only the specified keys.
|
|
72
|
+
def slice(*keys)
|
|
73
|
+
keys.each_with_object({}) do |k, h|
|
|
74
|
+
sym = k.to_sym
|
|
75
|
+
h[sym] = @attributes[sym] if @attributes.key?(sym)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ── Status ───────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def success?
|
|
82
|
+
!@failure
|
|
83
|
+
end
|
|
84
|
+
alias ok? success?
|
|
85
|
+
|
|
86
|
+
def failure?
|
|
87
|
+
@failure
|
|
88
|
+
end
|
|
89
|
+
alias failed? failure?
|
|
90
|
+
|
|
91
|
+
# ── Fail! ────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
# Mark the operation as failed. Accepts an optional hash of attributes to
|
|
94
|
+
# merge into ctx before raising (e.g. error:, errors:).
|
|
95
|
+
def fail!(attrs = {})
|
|
96
|
+
merge!(attrs)
|
|
97
|
+
@failure = true
|
|
98
|
+
raise Failure, self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ── Error conveniences ───────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def error
|
|
104
|
+
self[:error]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def error=(msg)
|
|
108
|
+
self[:error] = msg
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def errors
|
|
112
|
+
self[:errors] || {}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def errors=(hash)
|
|
116
|
+
self[:errors] = hash
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ── Chainable result callbacks ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def on_success
|
|
122
|
+
yield self if success?
|
|
123
|
+
self
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def on_failure
|
|
127
|
+
yield self if failure?
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ── Rollback support ──────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
# Called by Flow to track which operations have run.
|
|
134
|
+
def called!(operation)
|
|
135
|
+
@called << operation
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Roll back already-called operations in reverse order.
|
|
140
|
+
# Errors in individual rollbacks are swallowed to ensure all run.
|
|
141
|
+
def rollback!
|
|
142
|
+
return if @rolled_back
|
|
143
|
+
@rolled_back = true
|
|
144
|
+
@called.reverse_each do |op|
|
|
145
|
+
op.rollback rescue nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── Pattern matching ──────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
# Supports: case result; in { success: true, user: } ...
|
|
152
|
+
def deconstruct_keys(keys)
|
|
153
|
+
base = { success: success?, failure: failure? }
|
|
154
|
+
base.merge(@attributes).then do |all|
|
|
155
|
+
keys ? all.slice(*keys) : all
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ── Dynamic attribute access (method_missing) ─────────────────────────────
|
|
160
|
+
|
|
161
|
+
def method_missing(name, *args)
|
|
162
|
+
key = name.to_s
|
|
163
|
+
if key.end_with?("=")
|
|
164
|
+
self[key.chomp("=")] = args.first
|
|
165
|
+
elsif key.end_with?("?")
|
|
166
|
+
base = key.chomp("?").to_sym
|
|
167
|
+
return !!self[base]
|
|
168
|
+
elsif @attributes.key?(name.to_sym)
|
|
169
|
+
self[name]
|
|
170
|
+
else
|
|
171
|
+
super
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def respond_to_missing?(name, include_private = false)
|
|
176
|
+
key = name.to_s
|
|
177
|
+
return true if key.end_with?("=")
|
|
178
|
+
return true if key.end_with?("?")
|
|
179
|
+
@attributes.key?(name.to_sym) || super
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def inspect
|
|
183
|
+
status = @failure ? "FAILED" : "ok"
|
|
184
|
+
"#<Easyop::Ctx #{@attributes.inspect} [#{status}]>"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/easyop/flow.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
# Compose a sequence of operations that share a single ctx.
|
|
3
|
+
#
|
|
4
|
+
# If any step calls ctx.fail!, execution halts and rollback runs in reverse.
|
|
5
|
+
# Each step can define a `rollback` method which will be called on failure.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class ProcessOrder
|
|
9
|
+
# include Easyop::Flow
|
|
10
|
+
#
|
|
11
|
+
# flow ValidateCart, ChargeCard, CreateOrder, NotifyUser
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# result = ProcessOrder.call(user: user, cart: cart)
|
|
15
|
+
# result.on_success { |ctx| redirect_to order_path(ctx.order) }
|
|
16
|
+
# result.on_failure { |ctx| flash[:alert] = ctx.error }
|
|
17
|
+
#
|
|
18
|
+
# Steps are run via `.call!` so a failure raises and stops the chain.
|
|
19
|
+
# Individual steps can also be conditionally skipped:
|
|
20
|
+
#
|
|
21
|
+
# flow ValidateCart,
|
|
22
|
+
# -> (ctx) { ctx.coupon_code? }, ApplyCoupon, # conditional
|
|
23
|
+
# ChargeCard,
|
|
24
|
+
# CreateOrder
|
|
25
|
+
#
|
|
26
|
+
# A Lambda/Proc before a step is treated as a guard — the step only runs
|
|
27
|
+
# if the lambda returns truthy when called with ctx.
|
|
28
|
+
module Flow
|
|
29
|
+
# Prepended so that Flow's `call` takes precedence over Operation's no-op
|
|
30
|
+
# even though Operation is included inside Flow.included (which would
|
|
31
|
+
# otherwise place Operation earlier in the ancestor chain than Flow itself).
|
|
32
|
+
module CallBehavior
|
|
33
|
+
def call
|
|
34
|
+
pending_guard = nil
|
|
35
|
+
|
|
36
|
+
self.class._flow_steps.each do |step|
|
|
37
|
+
if step.is_a?(Proc)
|
|
38
|
+
pending_guard = step
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Evaluate lambda guard if present (placed before step in flow list)
|
|
43
|
+
if pending_guard
|
|
44
|
+
skip = !pending_guard.call(ctx)
|
|
45
|
+
pending_guard = nil
|
|
46
|
+
next if skip
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Evaluate class-level skip_if predicate declared on the step itself
|
|
50
|
+
next if step.respond_to?(:skip?) && step.skip?(ctx)
|
|
51
|
+
|
|
52
|
+
instance = step.new
|
|
53
|
+
instance._easyop_run(ctx, raise_on_failure: true)
|
|
54
|
+
ctx.called!(instance)
|
|
55
|
+
end
|
|
56
|
+
rescue Ctx::Failure
|
|
57
|
+
ctx.rollback!
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.included(base)
|
|
63
|
+
base.include(Operation)
|
|
64
|
+
base.extend(ClassMethods)
|
|
65
|
+
base.prepend(CallBehavior)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module ClassMethods
|
|
69
|
+
# Declare the ordered list of operation classes (and optional guards).
|
|
70
|
+
def flow(*steps)
|
|
71
|
+
@_flow_steps = steps.flatten
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def _flow_steps
|
|
75
|
+
@_flow_steps ||= []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns a FlowBuilder for pre-registering callbacks before .call.
|
|
79
|
+
#
|
|
80
|
+
# ProcessCheckout.prepare
|
|
81
|
+
# .on_success { |ctx| redirect_to order_path(ctx.order) }
|
|
82
|
+
# .on_failure { |ctx| flash[:error] = ctx.error }
|
|
83
|
+
# .call(user: current_user, cart: current_cart)
|
|
84
|
+
#
|
|
85
|
+
# ProcessCheckout.prepare
|
|
86
|
+
# .bind_with(self)
|
|
87
|
+
# .on(success: :order_placed, fail: :show_errors)
|
|
88
|
+
# .call(user: current_user, cart: current_cart)
|
|
89
|
+
def prepare
|
|
90
|
+
FlowBuilder.new(self)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
# FlowBuilder accumulates callbacks before executing a flow.
|
|
3
|
+
# Returned by FlowClass.flow (no args) and FlowClass.result.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# ProcessCheckout.flow
|
|
7
|
+
# .on_success { |ctx| redirect_to order_path(ctx.order) }
|
|
8
|
+
# .on_failure { |ctx| flash[:error] = ctx.error; redirect_back }
|
|
9
|
+
# .call(user: current_user, cart: current_cart)
|
|
10
|
+
#
|
|
11
|
+
# # With bound object (e.g. a Rails controller):
|
|
12
|
+
# ProcessCheckout.flow
|
|
13
|
+
# .bind_with(self)
|
|
14
|
+
# .on(success: :redirect_to_dashboard, fail: :render_form)
|
|
15
|
+
# .call(user: current_user, cart: current_cart)
|
|
16
|
+
class FlowBuilder
|
|
17
|
+
def initialize(flow_class)
|
|
18
|
+
@flow_class = flow_class
|
|
19
|
+
@success_callbacks = []
|
|
20
|
+
@failure_callbacks = []
|
|
21
|
+
@bound_object = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Register a callback to run when the flow succeeds.
|
|
25
|
+
def on_success(&block)
|
|
26
|
+
@success_callbacks << block
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Register a callback to run when the flow fails.
|
|
31
|
+
def on_failure(&block)
|
|
32
|
+
@failure_callbacks << block
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Bind a context object for use with symbol shortcuts in `.on(...)`.
|
|
37
|
+
# Typically `self` in a Rails controller.
|
|
38
|
+
def bind_with(obj)
|
|
39
|
+
@bound_object = obj
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Register named-method callbacks. Requires bind_with to have been called
|
|
44
|
+
# when the methods live on another object.
|
|
45
|
+
#
|
|
46
|
+
# .on(success: :redirect_to_dashboard, fail: :render_form)
|
|
47
|
+
def on(success: nil, fail: nil)
|
|
48
|
+
bound = @bound_object
|
|
49
|
+
if success
|
|
50
|
+
success_name = success
|
|
51
|
+
@success_callbacks << ->(ctx) { _invoke_named(bound, success_name, ctx) }
|
|
52
|
+
end
|
|
53
|
+
if fail
|
|
54
|
+
fail_name = fail
|
|
55
|
+
@failure_callbacks << ->(ctx) { _invoke_named(bound, fail_name, ctx) }
|
|
56
|
+
end
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Execute the flow with the given attributes, then fire the registered callbacks.
|
|
61
|
+
# Returns the ctx (Easyop::Ctx).
|
|
62
|
+
def call(attrs = {})
|
|
63
|
+
ctx = @flow_class.call(attrs)
|
|
64
|
+
callbacks = ctx.success? ? @success_callbacks : @failure_callbacks
|
|
65
|
+
callbacks.each { |cb| cb.call(ctx) }
|
|
66
|
+
ctx
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def _invoke_named(obj, name, ctx)
|
|
72
|
+
if obj
|
|
73
|
+
m = obj.method(name)
|
|
74
|
+
m.arity == 0 ? m.call : m.call(ctx)
|
|
75
|
+
else
|
|
76
|
+
raise ArgumentError, "bind_with(obj) must be called before using symbol callbacks in .on()"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/easyop/hooks.rb
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
# Lightweight before/after/around hook system with no ActiveSupport dependency.
|
|
3
|
+
#
|
|
4
|
+
# Hook lists are inherited — a subclass starts with a copy of its parent's
|
|
5
|
+
# hooks and may add its own without affecting the parent.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# before :method_name
|
|
9
|
+
# before { ctx.email = ctx.email.downcase }
|
|
10
|
+
# after :send_notification
|
|
11
|
+
# around :with_logging
|
|
12
|
+
# around { |inner| Sentry.with_scope { inner.call } }
|
|
13
|
+
#
|
|
14
|
+
# Execution order:
|
|
15
|
+
# around hooks wrap everything (outermost first).
|
|
16
|
+
# Inside the around chain: before hooks → call → after hooks.
|
|
17
|
+
# after hooks run in an `ensure` block so they always execute.
|
|
18
|
+
module Hooks
|
|
19
|
+
def self.included(base)
|
|
20
|
+
base.extend(ClassMethods)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module ClassMethods
|
|
24
|
+
# Add a before hook (method name or block).
|
|
25
|
+
def before(*methods, &block)
|
|
26
|
+
methods.each { |m| _before_hooks << m }
|
|
27
|
+
_before_hooks << block if block_given?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Add an after hook (method name or block).
|
|
31
|
+
def after(*methods, &block)
|
|
32
|
+
methods.each { |m| _after_hooks << m }
|
|
33
|
+
_after_hooks << block if block_given?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add an around hook (method name or block).
|
|
37
|
+
# The hook must yield (or call its first argument) to continue the chain.
|
|
38
|
+
def around(*methods, &block)
|
|
39
|
+
methods.each { |m| _around_hooks << m }
|
|
40
|
+
_around_hooks << block if block_given?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Hook lists, inherited from superclass (returns a dup so additions
|
|
44
|
+
# on a subclass don't pollute the parent).
|
|
45
|
+
def _before_hooks
|
|
46
|
+
@_before_hooks ||= _inherited_hooks(:_before_hooks)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def _after_hooks
|
|
50
|
+
@_after_hooks ||= _inherited_hooks(:_after_hooks)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def _around_hooks
|
|
54
|
+
@_around_hooks ||= _inherited_hooks(:_around_hooks)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def _inherited_hooks(name)
|
|
60
|
+
parent = superclass
|
|
61
|
+
parent.respond_to?(name, true) ? parent.send(name).dup : []
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Run the full hook chain around the user's `call` method.
|
|
66
|
+
# around hooks wrap before+call+after; after hooks always run (ensure).
|
|
67
|
+
def with_hooks(&block)
|
|
68
|
+
inner = proc do
|
|
69
|
+
run_hooks(self.class._before_hooks)
|
|
70
|
+
begin
|
|
71
|
+
block.call
|
|
72
|
+
ensure
|
|
73
|
+
run_hooks(self.class._after_hooks)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
call_through_around(self.class._around_hooks, inner)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def run_hooks(hooks)
|
|
83
|
+
hooks.each do |hook|
|
|
84
|
+
case hook
|
|
85
|
+
when Symbol then send(hook)
|
|
86
|
+
when Proc then instance_exec(&hook)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build a nested lambda chain so the first around hook is the outermost.
|
|
92
|
+
def call_through_around(around_hooks, inner)
|
|
93
|
+
chain = around_hooks.reverse.reduce(inner) do |acc, hook|
|
|
94
|
+
proc do
|
|
95
|
+
case hook
|
|
96
|
+
when Symbol
|
|
97
|
+
# Method must accept a block: def with_logging; yield; end
|
|
98
|
+
send(hook) { acc.call }
|
|
99
|
+
when Proc
|
|
100
|
+
# Block receives a callable: around { |inner| ...; inner.call }
|
|
101
|
+
instance_exec(acc, &hook)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
chain.call
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
# The core module. Include this in any class to turn it into an operation.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# class DoSomething
|
|
6
|
+
# include Easyop::Operation
|
|
7
|
+
#
|
|
8
|
+
# def call
|
|
9
|
+
# ctx.fail!(error: "nope") unless ctx.allowed
|
|
10
|
+
# ctx.result = do_work(ctx.input)
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# ctx = DoSomething.call(input: "data", allowed: true)
|
|
15
|
+
# ctx.success? # => true
|
|
16
|
+
# ctx.result # => ...
|
|
17
|
+
module Operation
|
|
18
|
+
def self.included(base)
|
|
19
|
+
base.extend(ClassMethods)
|
|
20
|
+
base.include(Hooks)
|
|
21
|
+
base.include(Rescuable)
|
|
22
|
+
base.include(Skip)
|
|
23
|
+
base.include(Schema)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ── Class-level API ───────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
module ClassMethods
|
|
29
|
+
# Call the operation. Always returns ctx, never raises on ctx.fail!.
|
|
30
|
+
# Other (unhandled) exceptions propagate normally.
|
|
31
|
+
def call(attrs = {})
|
|
32
|
+
new._easyop_run(Ctx.build(attrs), raise_on_failure: false)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Call the operation. Returns ctx on success, raises Ctx::Failure on fail!.
|
|
36
|
+
def call!(attrs = {})
|
|
37
|
+
new._easyop_run(Ctx.build(attrs), raise_on_failure: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Install a plugin onto this operation class.
|
|
41
|
+
#
|
|
42
|
+
# plugin Easyop::Plugins::Instrumentation
|
|
43
|
+
# plugin Easyop::Plugins::Recording, model: OperationLog
|
|
44
|
+
# plugin Easyop::Plugins::Async, queue: "operations"
|
|
45
|
+
#
|
|
46
|
+
# The plugin must respond to `.install(base_class, **options)`.
|
|
47
|
+
def plugin(plugin_mod, **options)
|
|
48
|
+
plugin_mod.install(self, **options)
|
|
49
|
+
_registered_plugins << { plugin: plugin_mod, options: options }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def _registered_plugins
|
|
53
|
+
@_registered_plugins ||= []
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── Instance API ─────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
# The shared context. Available inside `call`, hooks, and rescue handlers.
|
|
60
|
+
def ctx
|
|
61
|
+
@ctx
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Override this in subclasses.
|
|
65
|
+
def call
|
|
66
|
+
# no-op default
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Override to add rollback logic for use in Flow.
|
|
70
|
+
def rollback
|
|
71
|
+
# no-op default
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
# @api private — called by ClassMethods.call / call!
|
|
77
|
+
def _easyop_run(ctx, raise_on_failure:)
|
|
78
|
+
@ctx = ctx
|
|
79
|
+
if raise_on_failure
|
|
80
|
+
_run_raising
|
|
81
|
+
else
|
|
82
|
+
_run_safe
|
|
83
|
+
end
|
|
84
|
+
ctx
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# run! — propagates Ctx::Failure to caller
|
|
90
|
+
def _run_raising
|
|
91
|
+
with_hooks { call }
|
|
92
|
+
rescue Ctx::Failure
|
|
93
|
+
raise
|
|
94
|
+
rescue => e
|
|
95
|
+
raise unless rescue_with_handler(e)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# run — swallows Ctx::Failure (ctx.failure? will be true)
|
|
99
|
+
def _run_safe
|
|
100
|
+
with_hooks { call }
|
|
101
|
+
rescue Ctx::Failure
|
|
102
|
+
# swallow — caller checks ctx.failure?
|
|
103
|
+
rescue => e
|
|
104
|
+
begin
|
|
105
|
+
unless rescue_with_handler(e)
|
|
106
|
+
# Unhandled exception: mark ctx failed and re-raise
|
|
107
|
+
@ctx.fail!(error: e.message) rescue nil
|
|
108
|
+
raise e
|
|
109
|
+
end
|
|
110
|
+
rescue Ctx::Failure
|
|
111
|
+
# The rescue handler itself called ctx.fail! — swallow it
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|