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
data/README.md
ADDED
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
# EasyOp
|
|
2
|
+
|
|
3
|
+
[](https://pniemczyk.github.io/easyop/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](CHANGELOG.md)
|
|
6
|
+
|
|
7
|
+
**[📖 Documentation](https://pniemczyk.github.io/easyop/)** | **[GitHub](https://github.com/pniemczyk/easyop)** | **[Changelog](CHANGELOG.md)**
|
|
8
|
+
|
|
9
|
+
A joyful, opinionated Ruby gem for wrapping business logic in composable operations.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class AuthenticateUser
|
|
13
|
+
include Easyop::Operation
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
user = User.authenticate(ctx.email, ctx.password)
|
|
17
|
+
ctx.fail!(error: "Invalid credentials") unless user
|
|
18
|
+
ctx.user = user
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
result = AuthenticateUser.call(email: "alice@example.com", password: "hunter2")
|
|
23
|
+
result.success? # => true
|
|
24
|
+
result.user # => #<User ...>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# Gemfile
|
|
31
|
+
gem "easyop"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
bundle install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
Every operation:
|
|
41
|
+
- includes `Easyop::Operation`
|
|
42
|
+
- defines a `call` method that reads/writes `ctx`
|
|
43
|
+
- returns `ctx` from `.call` — the shared data bag that doubles as the result object
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class DoubleNumber
|
|
47
|
+
include Easyop::Operation
|
|
48
|
+
|
|
49
|
+
def call
|
|
50
|
+
ctx.fail!(error: "input must be a number") unless ctx.number.is_a?(Numeric)
|
|
51
|
+
ctx.result = ctx.number * 2
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result = DoubleNumber.call(number: 21)
|
|
56
|
+
result.success? # => true
|
|
57
|
+
result.result # => 42
|
|
58
|
+
|
|
59
|
+
result = DoubleNumber.call(number: "oops")
|
|
60
|
+
result.failure? # => true
|
|
61
|
+
result.error # => "input must be a number"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## The `ctx` Object
|
|
67
|
+
|
|
68
|
+
`ctx` is the shared data bag — a Hash-backed object with method-style attribute access. It is passed in from the caller and returned as the result.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# Reading
|
|
72
|
+
ctx.email # method access
|
|
73
|
+
ctx[:email] # hash-style access
|
|
74
|
+
ctx.admin? # predicate: !!ctx[:admin]
|
|
75
|
+
ctx.key?(:email) # explicit existence check (true/false)
|
|
76
|
+
|
|
77
|
+
# Writing
|
|
78
|
+
ctx.user = user
|
|
79
|
+
ctx[:user] = user
|
|
80
|
+
ctx.merge!(user: user, token: "abc")
|
|
81
|
+
|
|
82
|
+
# Extracting a subset
|
|
83
|
+
ctx.slice(:name, :email) # => { name: "Alice", email: "alice@example.com" }
|
|
84
|
+
ctx.to_h # => plain Hash copy of all attributes
|
|
85
|
+
|
|
86
|
+
# Status
|
|
87
|
+
ctx.success? # true unless fail! was called
|
|
88
|
+
ctx.ok? # alias for success?
|
|
89
|
+
ctx.failure? # true after fail!
|
|
90
|
+
ctx.failed? # alias for failure?
|
|
91
|
+
|
|
92
|
+
# Fail fast
|
|
93
|
+
ctx.fail! # mark failed
|
|
94
|
+
ctx.fail!(error: "Bad input") # merge attrs then fail
|
|
95
|
+
ctx.fail!(error: "Validation failed", errors: { email: "is blank" })
|
|
96
|
+
|
|
97
|
+
# Error helpers
|
|
98
|
+
ctx.error # => ctx[:error]
|
|
99
|
+
ctx.errors # => ctx[:errors] || {}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `Ctx::Failure` exception
|
|
103
|
+
|
|
104
|
+
`ctx.fail!` raises `Easyop::Ctx::Failure`, a `StandardError` subclass. The exception's `.ctx` attribute holds the failed context, and `.message` is formatted as:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
"Operation failed" # when ctx.error is nil
|
|
108
|
+
"Operation failed: <ctx.error>" # when ctx.error is set
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
begin
|
|
113
|
+
AuthenticateUser.call!(email: email, password: password)
|
|
114
|
+
rescue Easyop::Ctx::Failure => e
|
|
115
|
+
e.ctx.error # => "Invalid credentials"
|
|
116
|
+
e.message # => "Operation failed: Invalid credentials"
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Rollback tracking (`called!` / `rollback!`)
|
|
121
|
+
|
|
122
|
+
These methods are used internally by `Easyop::Flow` to track which operations have run and to roll them back on failure. You generally do not call them directly, but they are part of the public `Ctx` API:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
ctx.called!(operation_instance) # register an operation as having run
|
|
126
|
+
ctx.rollback! # roll back all registered operations in reverse order
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`rollback!` is idempotent — calling it more than once has no additional effect.
|
|
130
|
+
|
|
131
|
+
### Chainable callbacks (post-call)
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
AuthenticateUser.call(email: email, password: password)
|
|
135
|
+
.on_success { |ctx| sign_in(ctx.user) }
|
|
136
|
+
.on_failure { |ctx| flash[:alert] = ctx.error }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Pattern matching (Ruby 3+)
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
case AuthenticateUser.call(email: email, password: password)
|
|
143
|
+
in { success: true, user: }
|
|
144
|
+
sign_in(user)
|
|
145
|
+
in { success: false, error: }
|
|
146
|
+
flash[:alert] = error
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Bang variant
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# .call — returns ctx, swallows failures (check ctx.failure?)
|
|
154
|
+
# .call! — returns ctx on success, raises Easyop::Ctx::Failure on failure
|
|
155
|
+
|
|
156
|
+
begin
|
|
157
|
+
ctx = AuthenticateUser.call!(email: email, password: password)
|
|
158
|
+
rescue Easyop::Ctx::Failure => e
|
|
159
|
+
e.ctx.error # => "Invalid credentials"
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Hooks
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class NormalizeEmail
|
|
169
|
+
include Easyop::Operation
|
|
170
|
+
|
|
171
|
+
before :strip_whitespace
|
|
172
|
+
after :log_result
|
|
173
|
+
around :with_timing
|
|
174
|
+
|
|
175
|
+
def call
|
|
176
|
+
ctx.normalized = ctx.email.downcase
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def strip_whitespace
|
|
182
|
+
ctx.email = ctx.email.to_s.strip
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def log_result
|
|
186
|
+
Rails.logger.info "Normalized: #{ctx.normalized}" if ctx.success?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def with_timing
|
|
190
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
+
yield
|
|
192
|
+
Rails.logger.info "Took #{((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)}ms"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Multiple hooks run in declaration order. `after` hooks always run (even on failure). Hooks can be method names (Symbol) or inline blocks:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
before { ctx.email = ctx.email.to_s.strip.downcase }
|
|
201
|
+
after { Rails.logger.info ctx.inspect }
|
|
202
|
+
around { |inner| Sentry.with_scope { inner.call } }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## `rescue_from`
|
|
208
|
+
|
|
209
|
+
Handle exceptions without polluting `call` with begin/rescue blocks:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class ParseJson
|
|
213
|
+
include Easyop::Operation
|
|
214
|
+
|
|
215
|
+
rescue_from JSON::ParserError do |e|
|
|
216
|
+
ctx.fail!(error: "Invalid JSON: #{e.message}")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def call
|
|
220
|
+
ctx.parsed = JSON.parse(ctx.raw)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Multiple handlers and `with:` method reference syntax:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
class ImportData
|
|
229
|
+
include Easyop::Operation
|
|
230
|
+
|
|
231
|
+
rescue_from CSV::MalformedCSVError, with: :handle_bad_csv
|
|
232
|
+
rescue_from ActiveRecord::RecordInvalid do |e|
|
|
233
|
+
ctx.fail!(error: e.message, errors: e.record.errors.to_h)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def call
|
|
237
|
+
# ...
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def handle_bad_csv(e)
|
|
243
|
+
ctx.fail!(error: "CSV is malformed: #{e.message}")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Handlers are checked in reverse inheritance order — child class handlers take priority over parent class handlers.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Typed Input/Output Schemas
|
|
253
|
+
|
|
254
|
+
Schemas are optional. Declare them to get early validation and inline documentation:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
class RegisterUser
|
|
258
|
+
include Easyop::Operation
|
|
259
|
+
|
|
260
|
+
params do
|
|
261
|
+
required :email, String
|
|
262
|
+
required :age, Integer
|
|
263
|
+
optional :plan, String, default: "free"
|
|
264
|
+
optional :admin, :boolean, default: false
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
result do
|
|
268
|
+
required :user, User
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def call
|
|
272
|
+
ctx.user = User.create!(ctx.slice(:email, :age, :plan))
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
result = RegisterUser.call(email: "alice@example.com", age: 30)
|
|
277
|
+
result.success? # => true
|
|
278
|
+
result.plan # => "free" (default applied)
|
|
279
|
+
|
|
280
|
+
result = RegisterUser.call(email: "bob@example.com")
|
|
281
|
+
result.failure? # => true
|
|
282
|
+
result.error # => "Missing required params field: age"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Type shorthands
|
|
286
|
+
|
|
287
|
+
| Symbol | Resolves to |
|
|
288
|
+
|--------|------------|
|
|
289
|
+
| `:boolean` | `TrueClass \| FalseClass` |
|
|
290
|
+
| `:string` | `String` |
|
|
291
|
+
| `:integer` | `Integer` |
|
|
292
|
+
| `:float` | `Float` |
|
|
293
|
+
| `:symbol` | `Symbol` |
|
|
294
|
+
| `:any` | any value |
|
|
295
|
+
|
|
296
|
+
Pass any Ruby class directly: `required :user, User`.
|
|
297
|
+
|
|
298
|
+
### `inputs` / `outputs` aliases
|
|
299
|
+
|
|
300
|
+
`inputs` is an alias for `params`, and `outputs` is an alias for `result`. They are interchangeable:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
class NormalizeAddress
|
|
304
|
+
include Easyop::Operation
|
|
305
|
+
|
|
306
|
+
inputs do
|
|
307
|
+
required :street, String
|
|
308
|
+
required :city, String
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
outputs do
|
|
312
|
+
required :formatted, String
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def call
|
|
316
|
+
ctx.formatted = "#{ctx.street}, #{ctx.city}"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Configuration
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
Easyop.configure do |c|
|
|
325
|
+
c.strict_types = false # true = ctx.fail! on type mismatch; false = warn (default)
|
|
326
|
+
c.type_adapter = :native # :none, :native (default), :literal, :dry, :active_model
|
|
327
|
+
end
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Reset to defaults (useful in tests):
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
Easyop.reset_config!
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Flow — Composing Operations
|
|
339
|
+
|
|
340
|
+
`Easyop::Flow` runs operations in sequence, sharing one `ctx`. Any failure halts the chain.
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
class ProcessCheckout
|
|
344
|
+
include Easyop::Flow
|
|
345
|
+
|
|
346
|
+
flow ValidateCart,
|
|
347
|
+
ApplyCoupon,
|
|
348
|
+
ChargePayment,
|
|
349
|
+
CreateOrder,
|
|
350
|
+
SendConfirmation
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
result = ProcessCheckout.call(user: current_user, cart: current_cart)
|
|
354
|
+
result.success? # => true
|
|
355
|
+
result.order # => #<Order ...>
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Rollback
|
|
359
|
+
|
|
360
|
+
Each step can define `rollback`. On failure, rollback runs on all completed steps in reverse:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
class ChargePayment
|
|
364
|
+
include Easyop::Operation
|
|
365
|
+
|
|
366
|
+
def call
|
|
367
|
+
ctx.charge = Stripe::Charge.create(amount: ctx.total, source: ctx.token)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def rollback
|
|
371
|
+
Stripe::Refund.create(charge: ctx.charge.id) if ctx.charge
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### `skip_if` — Optional steps
|
|
377
|
+
|
|
378
|
+
Declare when a step should be bypassed:
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
class ApplyCoupon
|
|
382
|
+
include Easyop::Operation
|
|
383
|
+
|
|
384
|
+
skip_if { |ctx| !ctx.coupon_code? || ctx.coupon_code.to_s.empty? }
|
|
385
|
+
|
|
386
|
+
def call
|
|
387
|
+
ctx.discount = CouponService.apply(ctx.coupon_code)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
class ProcessCheckout
|
|
392
|
+
include Easyop::Flow
|
|
393
|
+
|
|
394
|
+
flow ValidateCart,
|
|
395
|
+
ApplyCoupon, # automatically skipped when no coupon_code
|
|
396
|
+
ChargePayment,
|
|
397
|
+
CreateOrder
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Skipped steps are never added to the rollback list.
|
|
402
|
+
|
|
403
|
+
> **Note:** `skip_if` is evaluated by the Flow runner. Calling an operation directly (e.g. `MyOp.call(...)`) bypasses the skip check entirely — `skip_if` is a Flow concept, not an operation-level guard.
|
|
404
|
+
|
|
405
|
+
### Lambda guards (inline)
|
|
406
|
+
|
|
407
|
+
Place a lambda immediately before a step to gate it:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
flow ValidateCart,
|
|
411
|
+
->(ctx) { ctx.coupon_code? }, ApplyCoupon,
|
|
412
|
+
ChargePayment
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Nested flows
|
|
416
|
+
|
|
417
|
+
A Flow can be a step inside another Flow:
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
class ProcessOrder
|
|
421
|
+
include Easyop::Flow
|
|
422
|
+
flow ValidateCart, ChargePayment
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
class FullCheckout
|
|
426
|
+
include Easyop::Flow
|
|
427
|
+
flow ProcessOrder, SendConfirmation, NotifyAdmin
|
|
428
|
+
end
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## `prepare` — Pre-registered Callbacks
|
|
434
|
+
|
|
435
|
+
`FlowClass.prepare` returns a `FlowBuilder` that accumulates callbacks before executing the flow. The `flow` class method is reserved for declaring steps — `prepare` is the clear, unambiguous entry point for callback registration.
|
|
436
|
+
|
|
437
|
+
### Block callbacks
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
ProcessCheckout.prepare
|
|
441
|
+
.on_success { |ctx| redirect_to order_path(ctx.order) }
|
|
442
|
+
.on_failure { |ctx| flash[:error] = ctx.error; redirect_back }
|
|
443
|
+
.call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Symbol callbacks with `bind_with`
|
|
447
|
+
|
|
448
|
+
Bind a host object (e.g. a Rails controller) to dispatch to named methods:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
# In a Rails controller:
|
|
452
|
+
def create
|
|
453
|
+
ProcessCheckout.prepare
|
|
454
|
+
.bind_with(self)
|
|
455
|
+
.on(success: :order_created, fail: :checkout_failed)
|
|
456
|
+
.call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
private
|
|
460
|
+
|
|
461
|
+
def order_created(ctx)
|
|
462
|
+
redirect_to order_path(ctx.order)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def checkout_failed(ctx)
|
|
466
|
+
flash[:error] = ctx.error
|
|
467
|
+
render :new
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Zero-arity methods are supported (ctx is not passed):
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
def order_created
|
|
475
|
+
redirect_to orders_path
|
|
476
|
+
end
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Multiple callbacks
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
ProcessCheckout.prepare
|
|
483
|
+
.on_success { |ctx| Analytics.track("checkout", order_id: ctx.order.id) }
|
|
484
|
+
.on_success { |ctx| redirect_to order_path(ctx.order) }
|
|
485
|
+
.on_failure { |ctx| Rails.logger.error("Checkout failed: #{ctx.error}") }
|
|
486
|
+
.on_failure { |ctx| render json: { error: ctx.error }, status: 422 }
|
|
487
|
+
.call(attrs)
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Inheritance — Shared Base Class
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
class ApplicationOperation
|
|
496
|
+
include Easyop::Operation
|
|
497
|
+
|
|
498
|
+
rescue_from StandardError do |e|
|
|
499
|
+
Sentry.capture_exception(e)
|
|
500
|
+
ctx.fail!(error: "An unexpected error occurred")
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
class MyOp < ApplicationOperation
|
|
505
|
+
def call
|
|
506
|
+
# StandardError is caught and handled by ApplicationOperation
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## Plugins
|
|
514
|
+
|
|
515
|
+
EasyOp has an opt-in plugin system. Plugins are installed on an operation class (or a shared base class) with the `plugin` DSL. Every subclass inherits the plugins of its parent.
|
|
516
|
+
|
|
517
|
+
```ruby
|
|
518
|
+
class ApplicationOperation
|
|
519
|
+
include Easyop::Operation
|
|
520
|
+
|
|
521
|
+
plugin Easyop::Plugins::Instrumentation
|
|
522
|
+
plugin Easyop::Plugins::Recording, model: OperationLog
|
|
523
|
+
plugin Easyop::Plugins::Async, queue: "operations"
|
|
524
|
+
end
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
You can inspect which plugins have been installed on an operation class:
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
ApplicationOperation._registered_plugins
|
|
531
|
+
# => [
|
|
532
|
+
# { plugin: Easyop::Plugins::Instrumentation, options: {} },
|
|
533
|
+
# { plugin: Easyop::Plugins::Recording, options: { model: OperationLog } },
|
|
534
|
+
# { plugin: Easyop::Plugins::Async, options: { queue: "operations" } }
|
|
535
|
+
# ]
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Plugins are **not** required automatically — require the ones you use:
|
|
539
|
+
|
|
540
|
+
```ruby
|
|
541
|
+
require "easyop/plugins/instrumentation"
|
|
542
|
+
require "easyop/plugins/recording"
|
|
543
|
+
require "easyop/plugins/async"
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
### Plugin: Instrumentation
|
|
549
|
+
|
|
550
|
+
Emits an `ActiveSupport::Notifications` event after every operation call. Requires ActiveSupport (included with Rails).
|
|
551
|
+
|
|
552
|
+
```ruby
|
|
553
|
+
require "easyop/plugins/instrumentation"
|
|
554
|
+
|
|
555
|
+
class ApplicationOperation
|
|
556
|
+
include Easyop::Operation
|
|
557
|
+
plugin Easyop::Plugins::Instrumentation
|
|
558
|
+
end
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Event:** `"easyop.operation.call"`
|
|
562
|
+
|
|
563
|
+
**Payload:**
|
|
564
|
+
|
|
565
|
+
| Key | Type | Description |
|
|
566
|
+
|---|---|---|
|
|
567
|
+
| `:operation` | String | Class name, e.g. `"Users::Register"` |
|
|
568
|
+
| `:success` | Boolean | `true` unless `ctx.fail!` was called |
|
|
569
|
+
| `:error` | String \| nil | `ctx.error` on failure, `nil` on success |
|
|
570
|
+
| `:duration` | Float | Elapsed milliseconds |
|
|
571
|
+
| `:ctx` | `Easyop::Ctx` | The result object |
|
|
572
|
+
|
|
573
|
+
**Subscribe manually:**
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
ActiveSupport::Notifications.subscribe("easyop.operation.call") do |event|
|
|
577
|
+
p = event.payload
|
|
578
|
+
Rails.logger.info "[#{p[:operation]}] #{p[:success] ? 'ok' : 'FAILED'} (#{event.duration.round(1)}ms)"
|
|
579
|
+
end
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
**Built-in log subscriber** — add this to an initializer for zero-config logging:
|
|
583
|
+
|
|
584
|
+
```ruby
|
|
585
|
+
# config/initializers/easyop.rb
|
|
586
|
+
Easyop::Plugins::Instrumentation.attach_log_subscriber
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Output format:
|
|
590
|
+
```
|
|
591
|
+
[EasyOp] Users::Register ok (4.2ms)
|
|
592
|
+
[EasyOp] Users::Authenticate FAILED (1.1ms) — Invalid email or password
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
### Plugin: Recording
|
|
598
|
+
|
|
599
|
+
Persists every operation execution to an ActiveRecord model. Useful for audit trails, debugging, and performance monitoring.
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
require "easyop/plugins/recording"
|
|
603
|
+
|
|
604
|
+
class ApplicationOperation
|
|
605
|
+
include Easyop::Operation
|
|
606
|
+
plugin Easyop::Plugins::Recording, model: OperationLog
|
|
607
|
+
end
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
**Options:**
|
|
611
|
+
|
|
612
|
+
| Option | Default | Description |
|
|
613
|
+
|---|---|---|
|
|
614
|
+
| `model:` | required | ActiveRecord class to write logs into |
|
|
615
|
+
| `record_params:` | `true` | Set `false` to skip serializing ctx params |
|
|
616
|
+
|
|
617
|
+
**Required model columns:**
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
create_table :operation_logs do |t|
|
|
621
|
+
t.string :operation_name, null: false
|
|
622
|
+
t.boolean :success, null: false
|
|
623
|
+
t.string :error_message
|
|
624
|
+
t.text :params_data # JSON — ctx attrs (sensitive keys scrubbed)
|
|
625
|
+
t.float :duration_ms
|
|
626
|
+
t.datetime :performed_at, null: false
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
The plugin automatically scrubs these keys from `params_data` before persisting: `:password`, `:password_confirmation`, `:token`, `:secret`, `:api_key`. ActiveRecord objects are serialized as `{ id:, class: }` rather than their full representation.
|
|
631
|
+
|
|
632
|
+
**Opt out per class:**
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
class Newsletter::SendBroadcast < ApplicationOperation
|
|
636
|
+
recording false # skip logging for this operation
|
|
637
|
+
end
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Recording failures are swallowed and logged as warnings — a failed log write never breaks the operation.
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
### Plugin: Async
|
|
645
|
+
|
|
646
|
+
Adds `.call_async` to any operation class, enqueuing execution as an ActiveJob. Requires ActiveJob (included with Rails).
|
|
647
|
+
|
|
648
|
+
```ruby
|
|
649
|
+
require "easyop/plugins/async"
|
|
650
|
+
|
|
651
|
+
class Newsletter::SendBroadcast < ApplicationOperation
|
|
652
|
+
plugin Easyop::Plugins::Async, queue: "broadcasts"
|
|
653
|
+
end
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Enqueue immediately:**
|
|
657
|
+
|
|
658
|
+
```ruby
|
|
659
|
+
Newsletter::SendBroadcast.call_async(subject: "Hello", body: "World")
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**With scheduling:**
|
|
663
|
+
|
|
664
|
+
```ruby
|
|
665
|
+
# Run after a delay
|
|
666
|
+
Newsletter::SendBroadcast.call_async(attrs, wait: 10.minutes)
|
|
667
|
+
|
|
668
|
+
# Run at a specific time
|
|
669
|
+
Newsletter::SendBroadcast.call_async(attrs, wait_until: Date.tomorrow.noon)
|
|
670
|
+
|
|
671
|
+
# Override the queue at call time
|
|
672
|
+
Newsletter::SendBroadcast.call_async(attrs, queue: "low_priority")
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
**ActiveRecord objects** are serialized by `(class, id)` and re-fetched in the job:
|
|
676
|
+
|
|
677
|
+
```ruby
|
|
678
|
+
# This works — Article is serialized as { "__ar_class" => "Article", "__ar_id" => 42 }
|
|
679
|
+
Newsletter::SendBroadcast.call_async(article: @article, subject: "Hello")
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Only pass serializable values: `String`, `Integer`, `Float`, `Boolean`, `nil`, `Hash`, `Array`, or `ActiveRecord::Base`.
|
|
683
|
+
|
|
684
|
+
The plugin defines `Easyop::Plugins::Async::Job` lazily (on first call to `.call_async`) so you can require the plugin before ActiveJob loads.
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
### Plugin: Transactional
|
|
689
|
+
|
|
690
|
+
Wraps every operation call in a database transaction. On `ctx.fail!` or any unhandled exception the transaction is rolled back. Supports **ActiveRecord** and **Sequel**.
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
require "easyop/plugins/transactional"
|
|
694
|
+
|
|
695
|
+
# Per operation:
|
|
696
|
+
class TransferFunds < ApplicationOperation
|
|
697
|
+
plugin Easyop::Plugins::Transactional
|
|
698
|
+
|
|
699
|
+
def call
|
|
700
|
+
ctx.from_account.debit!(ctx.amount)
|
|
701
|
+
ctx.to_account.credit!(ctx.amount)
|
|
702
|
+
ctx.transaction_id = SecureRandom.uuid
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Or globally on ApplicationOperation — all subclasses get transactions:
|
|
707
|
+
class ApplicationOperation
|
|
708
|
+
include Easyop::Operation
|
|
709
|
+
plugin Easyop::Plugins::Transactional
|
|
710
|
+
end
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
Also works with the classic `include` style:
|
|
714
|
+
|
|
715
|
+
```ruby
|
|
716
|
+
class TransferFunds
|
|
717
|
+
include Easyop::Operation
|
|
718
|
+
include Easyop::Plugins::Transactional
|
|
719
|
+
end
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
**Opt out** per class when the parent has transactions enabled:
|
|
723
|
+
|
|
724
|
+
```ruby
|
|
725
|
+
class ReadOnlyReport < ApplicationOperation
|
|
726
|
+
transactional false # no transaction overhead for read-only ops
|
|
727
|
+
end
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
**Options:** none — the adapter is detected automatically (ActiveRecord first, then Sequel).
|
|
731
|
+
|
|
732
|
+
**Placement in the lifecycle:** The transaction wraps the entire `prepare` chain — before hooks, `call`, and after hooks all run inside the same transaction. If `ctx.fail!` is called (raising `Ctx::Failure`), the transaction rolls back.
|
|
733
|
+
|
|
734
|
+
**With Flow:** When using `Easyop::Plugins::Transactional` inside a Flow step, the transaction is scoped to that one step, not the whole flow. For a flow-wide transaction, include it on the flow class itself.
|
|
735
|
+
|
|
736
|
+
---
|
|
737
|
+
|
|
738
|
+
### Building Your Own Plugin
|
|
739
|
+
|
|
740
|
+
A plugin is any object responding to `.install(base_class, **options)`. Inherit from `Easyop::Plugins::Base` for a clear interface:
|
|
741
|
+
|
|
742
|
+
```ruby
|
|
743
|
+
require "easyop/plugins/base"
|
|
744
|
+
|
|
745
|
+
module MyPlugin < Easyop::Plugins::Base
|
|
746
|
+
def self.install(base, **options)
|
|
747
|
+
# 1. Prepend a module to wrap _easyop_run (wraps the entire lifecycle)
|
|
748
|
+
base.prepend(RunWrapper)
|
|
749
|
+
|
|
750
|
+
# 2. Extend to add class-level DSL
|
|
751
|
+
base.extend(ClassMethods)
|
|
752
|
+
|
|
753
|
+
# 3. Store configuration on the class
|
|
754
|
+
base.instance_variable_set(:@_my_plugin_option, options[:my_option])
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
module ClassMethods
|
|
758
|
+
# DSL method for subclasses to configure the plugin
|
|
759
|
+
def my_plugin_option(value)
|
|
760
|
+
@_my_plugin_option = value
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def _my_plugin_option
|
|
764
|
+
@_my_plugin_option ||
|
|
765
|
+
(superclass.respond_to?(:_my_plugin_option) ? superclass._my_plugin_option : nil)
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
module RunWrapper
|
|
770
|
+
# Override _easyop_run to wrap the full operation lifecycle.
|
|
771
|
+
# Always call super and return ctx.
|
|
772
|
+
def _easyop_run(ctx, raise_on_failure:)
|
|
773
|
+
# before
|
|
774
|
+
puts "Starting #{self.class.name}"
|
|
775
|
+
|
|
776
|
+
super.tap do
|
|
777
|
+
# after (ctx is fully settled — success? / failure? are final here)
|
|
778
|
+
puts "Finished #{self.class.name}: #{ctx.success? ? 'ok' : 'FAILED'}"
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
**Activate it:**
|
|
786
|
+
|
|
787
|
+
```ruby
|
|
788
|
+
class ApplicationOperation
|
|
789
|
+
include Easyop::Operation
|
|
790
|
+
plugin MyPlugin, my_option: "value"
|
|
791
|
+
end
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
**Per-class opt-out pattern** (same pattern used by the Recording plugin):
|
|
795
|
+
|
|
796
|
+
```ruby
|
|
797
|
+
module MyPlugin < Easyop::Plugins::Base
|
|
798
|
+
module ClassMethods
|
|
799
|
+
def my_plugin(enabled)
|
|
800
|
+
@_my_plugin_enabled = enabled
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def _my_plugin_enabled?
|
|
804
|
+
return @_my_plugin_enabled if instance_variable_defined?(:@_my_plugin_enabled)
|
|
805
|
+
superclass.respond_to?(:_my_plugin_enabled?) ? superclass._my_plugin_enabled? : true
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
module RunWrapper
|
|
810
|
+
def _easyop_run(ctx, raise_on_failure:)
|
|
811
|
+
return super unless self.class._my_plugin_enabled?
|
|
812
|
+
# ... plugin logic
|
|
813
|
+
super
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Then in an operation:
|
|
819
|
+
class InternalOp < ApplicationOperation
|
|
820
|
+
my_plugin false
|
|
821
|
+
end
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
**Plugin execution order** is determined by the order `plugin` calls appear. Each plugin prepends its `RunWrapper`, so the last plugin installed is the outermost wrapper:
|
|
825
|
+
|
|
826
|
+
```
|
|
827
|
+
Plugin3::RunWrapper (outermost)
|
|
828
|
+
Plugin2::RunWrapper
|
|
829
|
+
Plugin1::RunWrapper
|
|
830
|
+
prepare { before → call → after } (innermost)
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
**Naming convention:** prefix all internal instance methods with `_pluginname_` (e.g. `_recording_persist!`, `_async_serialize`) to avoid collisions with application code.
|
|
834
|
+
|
|
835
|
+
---
|
|
836
|
+
|
|
837
|
+
## Rails Controller Integration
|
|
838
|
+
|
|
839
|
+
### Pattern 1 — Inline callbacks
|
|
840
|
+
|
|
841
|
+
```ruby
|
|
842
|
+
class UsersController < ApplicationController
|
|
843
|
+
def create
|
|
844
|
+
CreateUser.call(user_params)
|
|
845
|
+
.on_success { |ctx| redirect_to profile_path(ctx.user) }
|
|
846
|
+
.on_failure { |ctx| render :new, locals: { errors: ctx.errors } }
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
### Pattern 2 — `prepare` and `bind_with`
|
|
852
|
+
|
|
853
|
+
```ruby
|
|
854
|
+
class CheckoutsController < ApplicationController
|
|
855
|
+
def create
|
|
856
|
+
ProcessCheckout.prepare
|
|
857
|
+
.bind_with(self)
|
|
858
|
+
.on(success: :checkout_complete, fail: :checkout_failed)
|
|
859
|
+
.call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
private
|
|
863
|
+
|
|
864
|
+
def checkout_complete(ctx)
|
|
865
|
+
redirect_to order_path(ctx.order), notice: "Order placed!"
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def checkout_failed(ctx)
|
|
869
|
+
flash[:error] = ctx.error
|
|
870
|
+
render :new
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### Pattern 3 — Pattern matching
|
|
876
|
+
|
|
877
|
+
```ruby
|
|
878
|
+
def create
|
|
879
|
+
case CreateUser.call(user_params)
|
|
880
|
+
in { success: true, user: }
|
|
881
|
+
redirect_to profile_path(user)
|
|
882
|
+
in { success: false, errors: Hash => errs }
|
|
883
|
+
render :new, locals: { errors: errs }
|
|
884
|
+
in { success: false, error: String => msg }
|
|
885
|
+
flash[:alert] = msg
|
|
886
|
+
render :new
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
---
|
|
892
|
+
|
|
893
|
+
## Full Checkout Example
|
|
894
|
+
|
|
895
|
+
```ruby
|
|
896
|
+
class ValidateCart
|
|
897
|
+
include Easyop::Operation
|
|
898
|
+
|
|
899
|
+
def call
|
|
900
|
+
ctx.fail!(error: "Cart is empty") if ctx.cart.items.empty?
|
|
901
|
+
ctx.total = ctx.cart.items.sum(&:price)
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
class ApplyCoupon
|
|
906
|
+
include Easyop::Operation
|
|
907
|
+
|
|
908
|
+
skip_if { |ctx| !ctx.coupon_code? || ctx.coupon_code.to_s.empty? }
|
|
909
|
+
|
|
910
|
+
def call
|
|
911
|
+
coupon = Coupon.find_by(code: ctx.coupon_code)
|
|
912
|
+
ctx.fail!(error: "Invalid coupon") unless coupon&.active?
|
|
913
|
+
ctx.total -= coupon.discount_amount
|
|
914
|
+
ctx.discount = coupon.discount_amount
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
class ChargePayment
|
|
919
|
+
include Easyop::Operation
|
|
920
|
+
|
|
921
|
+
def call
|
|
922
|
+
charge = Stripe::Charge.create(amount: ctx.total, source: ctx.payment_token)
|
|
923
|
+
ctx.charge = charge
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def rollback
|
|
927
|
+
Stripe::Refund.create(charge: ctx.charge.id) if ctx.charge
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
class CreateOrder
|
|
932
|
+
include Easyop::Operation
|
|
933
|
+
|
|
934
|
+
def call
|
|
935
|
+
ctx.order = Order.create!(
|
|
936
|
+
user: ctx.user,
|
|
937
|
+
total: ctx.total,
|
|
938
|
+
charge: ctx.charge.id,
|
|
939
|
+
discount: ctx.discount
|
|
940
|
+
)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def rollback
|
|
944
|
+
ctx.order.destroy! if ctx.order
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
class SendConfirmation
|
|
949
|
+
include Easyop::Operation
|
|
950
|
+
|
|
951
|
+
def call
|
|
952
|
+
OrderMailer.confirmation(ctx.order).deliver_later
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
class ProcessCheckout
|
|
957
|
+
include Easyop::Flow
|
|
958
|
+
|
|
959
|
+
flow ValidateCart,
|
|
960
|
+
ApplyCoupon,
|
|
961
|
+
ChargePayment,
|
|
962
|
+
CreateOrder,
|
|
963
|
+
SendConfirmation
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# Controller:
|
|
967
|
+
ProcessCheckout.prepare
|
|
968
|
+
.bind_with(self)
|
|
969
|
+
.on(success: :order_created, fail: :checkout_failed)
|
|
970
|
+
.call(
|
|
971
|
+
user: current_user,
|
|
972
|
+
cart: current_cart,
|
|
973
|
+
payment_token: params[:stripe_token],
|
|
974
|
+
coupon_code: params[:coupon_code]
|
|
975
|
+
)
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
---
|
|
979
|
+
|
|
980
|
+
## Running Examples
|
|
981
|
+
|
|
982
|
+
```
|
|
983
|
+
ruby examples/usage.rb
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
## Example Rails App
|
|
987
|
+
|
|
988
|
+
A full Rails 8 blog application demonstrating every EasyOp feature in real-world code lives in `/examples/easyop_test_app/`. It is **not included in the gem** — only in the repository.
|
|
989
|
+
|
|
990
|
+
```
|
|
991
|
+
/examples/easyop_test_app/
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
The app covers:
|
|
995
|
+
|
|
996
|
+
| Feature | Where to look |
|
|
997
|
+
|---|---|
|
|
998
|
+
| Basic operations | `app/operations/users/`, `app/operations/articles/` |
|
|
999
|
+
| Typed `params` schema | `app/operations/users/register.rb` |
|
|
1000
|
+
| `rescue_from` | `app/operations/application_operation.rb` |
|
|
1001
|
+
| Flow with rollback | `app/operations/flows/transfer_credits.rb` |
|
|
1002
|
+
| `skip_if` / lambda guards | `Flows::TransferCredits::ApplyFee` |
|
|
1003
|
+
| Instrumentation plugin | `ApplicationOperation` → `plugin Easyop::Plugins::Instrumentation` |
|
|
1004
|
+
| Recording plugin | `ApplicationOperation` → persists to `operation_logs` table |
|
|
1005
|
+
| Async plugin | `app/operations/newsletter/subscribe.rb` |
|
|
1006
|
+
| Transactional plugin | `ApplicationOperation` → all DB ops wrapped in transactions |
|
|
1007
|
+
| Rails controller integration | `app/controllers/articles_controller.rb`, `transfers_controller.rb` |
|
|
1008
|
+
|
|
1009
|
+
**Running the example app:**
|
|
1010
|
+
|
|
1011
|
+
```bash
|
|
1012
|
+
cd /examples/easyop_test_app
|
|
1013
|
+
bundle install
|
|
1014
|
+
bin/rails db:create db:migrate db:seed
|
|
1015
|
+
bin/rails server -p 3002
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
Seed accounts: `alice@example.com` / `password123` (500 credits), `bob`, `carol`, `dave` (0 credits — tests insufficient-funds error).
|
|
1019
|
+
|
|
1020
|
+
## Running Specs
|
|
1021
|
+
|
|
1022
|
+
```
|
|
1023
|
+
bundle exec rspec
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
---
|
|
1027
|
+
|
|
1028
|
+
## AI Tools — Claude Skill & LLM Context
|
|
1029
|
+
|
|
1030
|
+
EasyOp ships with two sets of AI helpers so any LLM can write idiomatic operations without you re-explaining the API.
|
|
1031
|
+
|
|
1032
|
+
### Claude Code Skill
|
|
1033
|
+
|
|
1034
|
+
Copy the plugin into your project and Claude will auto-activate whenever you mention operations, flows, or `easyop`:
|
|
1035
|
+
|
|
1036
|
+
```bash
|
|
1037
|
+
# From your project root
|
|
1038
|
+
cp -r path/to/easyop/claude-plugin/.claude-plugin .
|
|
1039
|
+
cp -r path/to/easyop/claude-plugin/skills .
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
Or reference it from your existing `CLAUDE.md`:
|
|
1043
|
+
|
|
1044
|
+
```markdown
|
|
1045
|
+
## EasyOp
|
|
1046
|
+
@path/to/easyop/claude-plugin/skills/easyop/SKILL.md
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
Once installed, Claude generates correct boilerplate for operations, flows, rollback, plugins, and RSpec tests — no copy-pasting the README.
|
|
1050
|
+
|
|
1051
|
+
### LLM Context Files (`llms/`)
|
|
1052
|
+
|
|
1053
|
+
| File | When to use |
|
|
1054
|
+
|---|---|
|
|
1055
|
+
| `llms/overview.md` | Before asking an AI to **modify or extend the gem** — covers the full file map and plugin architecture |
|
|
1056
|
+
| `llms/usage.md` | Before asking an AI to **write application code** — covers all patterns: basic ops, flows, plugins, Rails integration, testing |
|
|
1057
|
+
|
|
1058
|
+
Paste either file as a system message in Claude.ai / ChatGPT / Gemini, or use Cursor's "Add to context" feature before asking your question.
|
|
1059
|
+
|
|
1060
|
+
See the **[AI Tools docs page](https://pniemczyk.github.io/easyop/ai-tools.html)** for full details including programmatic usage with the Anthropic API.
|
|
1061
|
+
|
|
1062
|
+
---
|
|
1063
|
+
|
|
1064
|
+
## Module Reference
|
|
1065
|
+
|
|
1066
|
+
### Core
|
|
1067
|
+
|
|
1068
|
+
| Class/Module | Description |
|
|
1069
|
+
|---|---|
|
|
1070
|
+
| `Easyop::Operation` | Core mixin — include in any class to make it an operation |
|
|
1071
|
+
| `Easyop::Flow` | Includes `Operation`; adds `flow` DSL and sequential execution |
|
|
1072
|
+
| `Easyop::FlowBuilder` | Builder returned by `FlowClass.prepare` |
|
|
1073
|
+
| `Easyop::Ctx` | The shared context/result object |
|
|
1074
|
+
| `Easyop::Ctx::Failure` | Raised by `ctx.fail!`; rescued by `.call`, propagated by `.call!` |
|
|
1075
|
+
| `Easyop::Hooks` | `before`/`after`/`around` hook system (no ActiveSupport) |
|
|
1076
|
+
| `Easyop::Rescuable` | `rescue_from` DSL |
|
|
1077
|
+
| `Easyop::Skip` | `skip_if` DSL for conditional step execution in flows |
|
|
1078
|
+
| `Easyop::Schema` | `params`/`result` typed schema DSL |
|
|
1079
|
+
|
|
1080
|
+
### Plugins (opt-in)
|
|
1081
|
+
|
|
1082
|
+
| Class/Module | Require | Description |
|
|
1083
|
+
|---|---|---|
|
|
1084
|
+
| `Easyop::Plugins::Base` | `easyop/plugins/base` | Abstract base — inherit to build custom plugins |
|
|
1085
|
+
| `Easyop::Plugins::Instrumentation` | `easyop/plugins/instrumentation` | Emits `"easyop.operation.call"` via `ActiveSupport::Notifications` |
|
|
1086
|
+
| `Easyop::Plugins::Recording` | `easyop/plugins/recording` | Persists every execution to an ActiveRecord model |
|
|
1087
|
+
| `Easyop::Plugins::Async` | `easyop/plugins/async` | Adds `.call_async` via ActiveJob with AR object serialization |
|
|
1088
|
+
| `Easyop::Plugins::Async::Job` | (created lazily) | The ActiveJob class that deserializes and runs the operation |
|
|
1089
|
+
| `Easyop::Plugins::Transactional` | `easyop/plugins/transactional` | Wraps operation in an AR/Sequel transaction; `transactional false` to opt out |
|