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.
@@ -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
@@ -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
@@ -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