flowy 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/LICENSE.txt +21 -0
- data/README.md +873 -0
- data/lib/flowy/concern/step_runner.rb +135 -0
- data/lib/flowy/concern.rb +144 -0
- data/lib/flowy/enumerable.rb +29 -0
- data/lib/flowy/error.rb +47 -0
- data/lib/flowy/failure.rb +120 -0
- data/lib/flowy/pipeline.rb +194 -0
- data/lib/flowy/result.rb +63 -0
- data/lib/flowy/success.rb +74 -0
- data/lib/flowy/version.rb +3 -0
- data/lib/flowy.rb +11 -0
- metadata +70 -0
data/README.md
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
# Flowy
|
|
2
|
+
|
|
3
|
+
**Flowy** is a lightweight Ruby gem for building clean, composable service objects using the Result pattern (also known as Railway Oriented Programming).
|
|
4
|
+
|
|
5
|
+
Instead of raising exceptions or returning `true`/`false`, your service methods return typed `Success` or `Failure` objects that carry data or error information.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem 'flowy'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Core objects
|
|
16
|
+
|
|
17
|
+
### `Flowy::Success`
|
|
18
|
+
|
|
19
|
+
Represents a successful outcome. Carries a `data` hash (the result payload) and an optional `warnings` array.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
result = Flowy::Success.new(data: { user_id: 1 }, warnings: ['email not verified'])
|
|
23
|
+
|
|
24
|
+
result.success? # => true
|
|
25
|
+
result.failure? # => false
|
|
26
|
+
result.data # => { user_id: 1 }
|
|
27
|
+
result.warnings # => ['email not verified']
|
|
28
|
+
result.to_hash
|
|
29
|
+
# => { success: true, data: { user_id: 1 }, warnings: ['email not verified'] }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
You can also build one via the factory:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Flowy::Result.success(data: { id: 1 })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
#### Merging two successes
|
|
39
|
+
|
|
40
|
+
`+` performs a deep merge of the `data` hashes:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
a = Flowy::Success.new(data: { x: 1, meta: { a: 1 } })
|
|
44
|
+
b = Flowy::Success.new(data: { y: 2, meta: { b: 2 } })
|
|
45
|
+
(a + b).data # => { x: 1, y: 2, meta: { a: 1, b: 2 } }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
#### `merge_data`
|
|
49
|
+
|
|
50
|
+
Returns a **new** `Success` with data deep-merged. Accepts either a hash or a block receiving the current data:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
result.merge_data(role: :admin)
|
|
54
|
+
result.merge_data { |d| { count: d[:items].size } }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### `Flowy::Failure`
|
|
60
|
+
|
|
61
|
+
Represents a failed outcome. Carries a typed `error_code` symbol and optional contextual fields.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
result = Flowy::Failure.new(
|
|
65
|
+
error_code: :payment_declined,
|
|
66
|
+
error_data: { gateway: 'stripe', amount: 99 },
|
|
67
|
+
error_title: 'Payment declined',
|
|
68
|
+
error_description: 'The card was declined by the issuer'
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
result.failure? # => true
|
|
72
|
+
result.success? # => false
|
|
73
|
+
result.error_code # => :payment_declined
|
|
74
|
+
result.error_data # => { gateway: 'stripe', amount: 99 }
|
|
75
|
+
result.error_title # => 'Payment declined'
|
|
76
|
+
result.error_description # => 'The card was declined by the issuer'
|
|
77
|
+
result.to_hash
|
|
78
|
+
# => { success: false, error_code: :payment_declined, error_data: {...},
|
|
79
|
+
# error_title: 'Payment declined', error_description: '...' }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
You can also build one via the factory:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Flowy::Result.failure(error_code: :not_found, error_title: 'Not Found')
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### `merge_data`
|
|
89
|
+
|
|
90
|
+
Returns a **new** `Failure` with `error_data` deep-merged. All other attributes (including `parent_failure`) are preserved:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
result.merge_data(context: 'CreateUser')
|
|
94
|
+
result.merge_data { |d| d.merge(retryable: false) }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### `is?` — error_code predicate
|
|
98
|
+
|
|
99
|
+
Convenience predicate for matching `error_code`. Equivalent to `failure.error_code == code` but reads as a sentence at the call site:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
failure = Flowy::Failure.new(error_code: :not_found)
|
|
103
|
+
|
|
104
|
+
failure.is?(error_code: :not_found) # => true
|
|
105
|
+
failure.is?(error_code: :other) # => false
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### Chaining nested failures with `parent_failure`
|
|
109
|
+
|
|
110
|
+
When a service wraps a failure from a downstream service, set `parent_failure:` to preserve the full error history:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
inner = Flowy::Failure.new(error_code: :stripe_error)
|
|
114
|
+
outer = Flowy::Failure.new(error_code: :charge_failed, parent_failure: inner)
|
|
115
|
+
|
|
116
|
+
outer.failures_chain # => [inner, outer]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`failures_chain` traverses the chain from root to leaf, giving you the complete error trail for logging or debugging.
|
|
120
|
+
|
|
121
|
+
#### Raising a failure as an exception with `raise!`
|
|
122
|
+
|
|
123
|
+
Convert a `Failure` into a `Flowy::Error` and raise it. Useful when a failure must propagate through code that does not handle results (e.g. a callback, a background job framework, or a boundary where exceptions are expected):
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
result = PaymentService.new.call(data)
|
|
127
|
+
result.raise!
|
|
128
|
+
# => raises Flowy::Error (code: :payment_declined, title: '...', detail: '...', meta: {...})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The raised `Flowy::Error` is a `StandardError`, so it can be rescued normally:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
begin
|
|
135
|
+
PaymentService.new.call(data).raise!
|
|
136
|
+
rescue Flowy::Error => e
|
|
137
|
+
e.code # => :payment_declined
|
|
138
|
+
e.to_failure # => back to a Flowy::Failure
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`raise!` is a **no-op on `Success`** — it returns `self` unchanged, making it safe to attach unconditionally:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
ServiceB.new.call(data)
|
|
146
|
+
.raise! # raises only if Failure
|
|
147
|
+
.and_then { |r| do_more(r.data) } # continues only if Success
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### Wrapping failures from nested services with `map_failure`
|
|
151
|
+
|
|
152
|
+
When service A calls service B, you can translate B's failure into A's own vocabulary while automatically preserving the original as `parent_failure`:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# Block form — full control
|
|
156
|
+
PaymentService.new.call(data)
|
|
157
|
+
.map_failure { |f|
|
|
158
|
+
Flowy::Failure.new(
|
|
159
|
+
error_code: :charge_failed,
|
|
160
|
+
error_data: { reason: f.error_code },
|
|
161
|
+
error_description: 'Payment could not be completed'
|
|
162
|
+
# parent_failure is set automatically when omitted
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Shorthand form — no block needed
|
|
167
|
+
PaymentService.new.call(data)
|
|
168
|
+
.map_failure(error_code: :charge_failed, error_data: { source: :payment_service })
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`map_failure` is a **no-op on `Success`** — it returns `self` unchanged, making it safe to attach unconditionally:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
ServiceB.new.call(data)
|
|
175
|
+
.map_failure(error_code: :service_b_failed)
|
|
176
|
+
.and_then { |r| success(data: r.data.merge(done: true)) }
|
|
177
|
+
.on_failure { |r| puts r.failures_chain.map(&:error_code).inspect }
|
|
178
|
+
# => [:original_b_error, :service_b_failed]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### `Flowy::Error`
|
|
184
|
+
|
|
185
|
+
A `StandardError` subclass that bridges Flowy's result objects with Ruby's exception system. Use it when you need to raise an exception carrying the same structured data as a `Failure`.
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
error = Flowy::Error.new(
|
|
189
|
+
code: :payment_declined,
|
|
190
|
+
title: 'Payment declined',
|
|
191
|
+
detail: 'The card was declined by the issuer',
|
|
192
|
+
meta: { gateway: 'stripe' }
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
raise error
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`Flowy::Error` is also rescuable as a standard `StandardError`.
|
|
199
|
+
|
|
200
|
+
#### Building from a `Failure`
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
failure = Flowy::Failure.new(
|
|
204
|
+
error_code: :not_found,
|
|
205
|
+
error_title: 'Not found',
|
|
206
|
+
error_description: 'Record does not exist',
|
|
207
|
+
error_data: { id: 42 }
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
error = Flowy::Error.initialize_from_failure(failure: failure)
|
|
211
|
+
error.code # => :not_found
|
|
212
|
+
error.title # => 'Not found'
|
|
213
|
+
error.detail # => 'Record does not exist'
|
|
214
|
+
error.meta # => { id: 42 }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### Converting back to a `Failure`
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
error.to_failure # => Flowy::Failure
|
|
221
|
+
error.to_hash # => same structure as Failure#to_hash
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
### `Flowy::Result` — the union type
|
|
227
|
+
|
|
228
|
+
Both `Success` and `Failure` include `Flowy::Result`, enabling uniform type-checking:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
result.is_a?(Flowy::Result) # => true for both Success and Failure
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Factory methods:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
Flowy::Result.success(data: { id: 1 })
|
|
238
|
+
Flowy::Result.failure(error_code: :not_found, error_title: 'Not Found')
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
#### `Result.wrap` — adapter for exception-raising code
|
|
242
|
+
|
|
243
|
+
Executes a block and automatically converts its outcome into a `Success` or `Failure`. Useful for integrating third-party libraries or any code that raises exceptions:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
# Plain value → Success(data: { value: <User> })
|
|
247
|
+
result = Flowy::Result.wrap { User.find(id) }
|
|
248
|
+
|
|
249
|
+
# Existing Success/Failure → forwarded unchanged
|
|
250
|
+
result = Flowy::Result.wrap { some_service.call }
|
|
251
|
+
|
|
252
|
+
# Custom error_code and rescue classes
|
|
253
|
+
result = Flowy::Result.wrap(
|
|
254
|
+
rescue: [ActiveRecord::RecordNotFound],
|
|
255
|
+
error_code: :not_found,
|
|
256
|
+
error_title: 'Resource not found'
|
|
257
|
+
) { User.find(id) }
|
|
258
|
+
|
|
259
|
+
result.on_success { |r| puts r.data[:value] }
|
|
260
|
+
.on_failure { |r| puts r.error_code } # => :not_found
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
On failure the generated `Flowy::Failure` contains:
|
|
264
|
+
- `error_code` — `:wrapped_error` by default or the value passed via `error_code:`
|
|
265
|
+
- `error_data` — `{ error_class: '...', message: '...' }`
|
|
266
|
+
- `error_description` — the exception message
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
### Shared result methods
|
|
271
|
+
|
|
272
|
+
Both `Success` and `Failure` share the following chainable interface:
|
|
273
|
+
|
|
274
|
+
#### `on_success` / `on_failure`
|
|
275
|
+
|
|
276
|
+
Yields `self` only when the type matches; always returns `self`:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
OrderService.new.call
|
|
280
|
+
.on_success { |r| render json: r.data }
|
|
281
|
+
.on_failure { |r| render json: r.to_hash, status: :unprocessable_entity }
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
#### `and_then` / `or_else`
|
|
285
|
+
|
|
286
|
+
`and_then` pipes a `Success` into the next step; short-circuits on `Failure`:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
validate(params)
|
|
290
|
+
.and_then { |r| persist(r.data) }
|
|
291
|
+
.and_then { |r| notify(r.data) }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`or_else` is the symmetric counterpart — runs only on `Failure`, allowing recovery:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
fetch_from_cache(id)
|
|
298
|
+
.or_else { fetch_from_db(id) }
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Both methods require the block to return a `Flowy::Success` or `Flowy::Failure`.
|
|
302
|
+
|
|
303
|
+
#### `tap`
|
|
304
|
+
|
|
305
|
+
Yields `self` for side-effects (logging, telemetry) without modifying it:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
OrderService.new.call
|
|
309
|
+
.tap { |r| Rails.logger.info(r.to_hash) }
|
|
310
|
+
.on_success { |r| render json: r.data }
|
|
311
|
+
.on_failure { |r| render json: r.to_hash, status: :unprocessable_entity }
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Service objects with `Flowy::Concern`
|
|
317
|
+
|
|
318
|
+
Include `Flowy::Concern` in any class to get `success`, `failure`, and `run_steps`:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
class OrderService
|
|
322
|
+
include Flowy::Concern
|
|
323
|
+
|
|
324
|
+
def call
|
|
325
|
+
run_steps(
|
|
326
|
+
starting_data: { order_id: 42 },
|
|
327
|
+
steps: [:validate, :reserve_stock, :charge_payment]
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
def validate(previous_result:)
|
|
334
|
+
return failure(error_code: :invalid_order) unless valid?
|
|
335
|
+
success(data: previous_result.data)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def reserve_stock(previous_result:)
|
|
339
|
+
success(data: previous_result.data.merge(reserved: true))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def charge_payment(previous_result:)
|
|
343
|
+
success(data: previous_result.data.merge(charged: true))
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Step pipeline with `run_steps`
|
|
349
|
+
|
|
350
|
+
`run_steps` executes an ordered list of steps sequentially. Each step must return a `Success` or `Failure`. The pipeline short-circuits as soon as any step returns a `Failure`.
|
|
351
|
+
|
|
352
|
+
Steps can be:
|
|
353
|
+
- **Symbol** — name of an instance method on the service
|
|
354
|
+
- **Lambda / Proc** — any callable that accepts `previous_result:`
|
|
355
|
+
|
|
356
|
+
#### Step method signatures
|
|
357
|
+
|
|
358
|
+
Flowy inspects the keyword parameters declared by each step method and builds the call arguments automatically. You can choose the style that best communicates the method's contract:
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
# 1. Classic — receives the full result object
|
|
362
|
+
def persist(previous_result:)
|
|
363
|
+
user = User.create!(previous_result.data[:params])
|
|
364
|
+
success(data: { user: user })
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# 2. Data-keys — declares exactly which data keys it needs (self-documenting)
|
|
368
|
+
def notify(user:)
|
|
369
|
+
UserMailer.welcome(user).deliver_later
|
|
370
|
+
success
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# 3. Mixed — data keys + full result when both are needed
|
|
374
|
+
def charge(order_id:, amount:, previous_result:)
|
|
375
|
+
# use order_id and amount directly; inspect previous_result.warnings if needed
|
|
376
|
+
success(data: previous_result.data.merge(charged: true))
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# 4. With ** rest — captures any remaining data keys not declared explicitly
|
|
380
|
+
def forward(required_key:, **rest)
|
|
381
|
+
success(data: rest.merge(required_key: required_key))
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**Required vs optional keyword parameters**
|
|
386
|
+
|
|
387
|
+
- `keyreq` (e.g. `def step(n:)`) — Flowy raises an `ArgumentError` with a descriptive message if the key is absent from `result.data`.
|
|
388
|
+
- `key` with a default (e.g. `def step(label: 'default')`) — Flowy passes the value only when the key exists in `result.data`; otherwise Ruby uses the declared default.
|
|
389
|
+
|
|
390
|
+
**Reserved keyword: `previous_result`**
|
|
391
|
+
|
|
392
|
+
`previous_result` is a reserved parameter name. When a step method declares `previous_result:`, Flowy always passes the full `Flowy::Result` object, regardless of whether `previous_result` exists as a key in `result.data`. Avoid using `:previous_result` as a data key — it will be shadowed by the Result object (or, if the step does not declare `previous_result:`, will leak into the `**rest` hash).
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
class CreateUser
|
|
396
|
+
include Flowy::Concern
|
|
397
|
+
|
|
398
|
+
def call(params)
|
|
399
|
+
run_steps(
|
|
400
|
+
starting_data: { params: params },
|
|
401
|
+
steps: [:validate, :persist, :notify],
|
|
402
|
+
rescue_errors: true # converts uncaught exceptions to Failure
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
private
|
|
407
|
+
|
|
408
|
+
def validate(params:) # receives params directly from data
|
|
409
|
+
return failure(error_code: :invalid_params) if params.empty?
|
|
410
|
+
success(data: { params: params })
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def persist(params:) # only needs params
|
|
414
|
+
user = User.create!(params)
|
|
415
|
+
success(data: { user: user })
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def notify(user:, previous_result:) # data key + full result
|
|
419
|
+
UserMailer.welcome(user).deliver_later
|
|
420
|
+
success(data: previous_result.data)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Declarative step pipeline with `.step` / `.tap_step`
|
|
426
|
+
|
|
427
|
+
Steps can be declared at class level. `run_steps` uses them automatically when no explicit `steps:` array is passed:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
class CreateUser
|
|
431
|
+
include Flowy::Concern
|
|
432
|
+
|
|
433
|
+
step :validate
|
|
434
|
+
tap_step :log_audit # side-effect only; return value is ignored
|
|
435
|
+
step :persist
|
|
436
|
+
step :notify
|
|
437
|
+
|
|
438
|
+
def call(params)
|
|
439
|
+
run_steps(starting_data: { params: params })
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
private
|
|
443
|
+
|
|
444
|
+
def log_audit(**data) # receives all data keys via **rest
|
|
445
|
+
Rails.logger.info("[CreateUser] #{data.keys}")
|
|
446
|
+
# no need to return a result
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# ... other step methods
|
|
450
|
+
end
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Granular exception handling with `rescue:` / `on_error:`
|
|
454
|
+
|
|
455
|
+
Declare which exception classes a step can raise and how to handle them:
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
step :persist, rescue: [ActiveRecord::RecordInvalid], on_error: :handle_db_error
|
|
459
|
+
|
|
460
|
+
# Without on_error, the exception is converted to a generic Failure:
|
|
461
|
+
step :persist, rescue: [ActiveRecord::RecordInvalid]
|
|
462
|
+
# => error_code: :step_raised_error, error_data: { step: :persist, message: '...' }
|
|
463
|
+
|
|
464
|
+
def handle_db_error(error, previous_result:)
|
|
465
|
+
failure(
|
|
466
|
+
error_code: :persistence_failed,
|
|
467
|
+
error_data: { message: error.message, params: previous_result.data[:params] }
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### Step hooks: `before_step`, `after_step`, `around_step`
|
|
473
|
+
|
|
474
|
+
Flowy provides three composable hook types that fire around every step execution without touching the step implementations themselves.
|
|
475
|
+
|
|
476
|
+
| Hook | Fires | Can modify result? | Block signature |
|
|
477
|
+
|---|---|---|---|
|
|
478
|
+
| `before_step` | just **before** the step | ✗ side-effect only | `\|step_name, previous_result\|` |
|
|
479
|
+
| `after_step` | just **after** the step | ✗ side-effect only | `\|step_name, result\|` |
|
|
480
|
+
| `around_step` | **wraps** the step | ✓ must return a `Flowy::Result` | `\|step_name, previous_result, &call\|` |
|
|
481
|
+
|
|
482
|
+
Hooks can be registered at three scopes, applied in this order per step:
|
|
483
|
+
|
|
484
|
+
```
|
|
485
|
+
global before → class before → per-step before
|
|
486
|
+
global around [ class around [ per-step around [ step ] ] ]
|
|
487
|
+
per-step after → class after → global after
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
#### 1. Global hooks — `Flowy::Concern.<hook>`
|
|
491
|
+
|
|
492
|
+
Run for **every** service class that includes `Flowy::Concern`. Ideal for cross-cutting concerns such as tracing, metrics, and audit logging.
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
# config/initializers/flowy.rb
|
|
496
|
+
Flowy::Concern.before_step do |step_name, previous_result|
|
|
497
|
+
Current.audit_log << { step: step_name, at: Time.now }
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
Flowy::Concern.after_step do |step_name, result|
|
|
501
|
+
StatsD.increment("flowy.#{step_name}.#{result.success? ? 'success' : 'failure'}")
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
Flowy::Concern.around_step do |step_name, previous_result, &call|
|
|
505
|
+
OpenTelemetry::Tracer.in_span("flowy.#{step_name}") { call.() }
|
|
506
|
+
end
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Remove all global hooks (e.g. in test teardowns):
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
Flowy::Concern.clear_global_hooks!
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
#### 2. Class-level hooks — declared inside the service class
|
|
516
|
+
|
|
517
|
+
Run only for the service class they are declared on.
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
class CreateUser
|
|
521
|
+
include Flowy::Concern
|
|
522
|
+
|
|
523
|
+
before_step do |step_name, previous_result|
|
|
524
|
+
Rails.logger.debug "[CreateUser] starting #{step_name}"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
after_step do |step_name, result|
|
|
528
|
+
Rails.logger.debug "[CreateUser] #{step_name} → #{result.success? ? '✓' : '✗'}"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
around_step do |step_name, previous_result, &call|
|
|
532
|
+
t0 = Time.now
|
|
533
|
+
result = call.()
|
|
534
|
+
Rails.logger.info "[CreateUser] #{step_name} (#{((Time.now - t0) * 1000).round}ms)"
|
|
535
|
+
result
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
step :validate
|
|
539
|
+
step :persist
|
|
540
|
+
step :notify
|
|
541
|
+
end
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
#### 3. Per-step hooks — inline on `step` / `tap_step`
|
|
545
|
+
|
|
546
|
+
Scoped to a **single step**. Accept either a **Symbol** (name of an instance method) or any **callable** (lambda / proc).
|
|
547
|
+
|
|
548
|
+
```ruby
|
|
549
|
+
class CreateOrder
|
|
550
|
+
include Flowy::Concern
|
|
551
|
+
|
|
552
|
+
step :validate,
|
|
553
|
+
before_step: :log_start # Symbol → instance method
|
|
554
|
+
|
|
555
|
+
step :charge,
|
|
556
|
+
before_step: ->(name, prev) { Tracer.start(name) },
|
|
557
|
+
after_step: ->(name, res) { Tracer.finish(name, res.success?) },
|
|
558
|
+
around_step: :enforce_idempotency
|
|
559
|
+
|
|
560
|
+
step :persist,
|
|
561
|
+
rescue: [ActiveRecord::RecordInvalid],
|
|
562
|
+
on_error: :handle_db_error,
|
|
563
|
+
after_step: ->(name, res) { Rails.logger.info "persist: #{res.success?}" }
|
|
564
|
+
|
|
565
|
+
tap_step :audit_trail,
|
|
566
|
+
before_step: ->(name, _prev) { AuditLog.open(name) },
|
|
567
|
+
after_step: ->(name, _res) { AuditLog.close(name) }
|
|
568
|
+
|
|
569
|
+
private
|
|
570
|
+
|
|
571
|
+
def log_start(step_name, previous_result)
|
|
572
|
+
Rails.logger.info "Starting #{step_name}"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def enforce_idempotency(step_name, previous_result, &call)
|
|
576
|
+
IdempotencyGuard.wrap(step_name) { call.() }
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# ...
|
|
580
|
+
end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Per-step `around_step` can also short-circuit by returning a `Failure` without calling `call.()`:
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
step :charge, around_step: ->(name, prev, &_call) {
|
|
587
|
+
return Flowy::Result.failure(error_code: :dry_run) if DryRun.active?
|
|
588
|
+
_call.()
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
#### Notes
|
|
593
|
+
|
|
594
|
+
- `after_step` receives `previous_result` (not the step's raw return) for `tap_step`s, because tap-steps always forward the previous result.
|
|
595
|
+
- Multiple hooks of the same scope and type run in **registration order**.
|
|
596
|
+
- `around_step` blocks **must** return a `Flowy::Result`; a `TypeError` is raised otherwise.
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
## `Flowy::Pipeline` — composable pipelines as first-class objects
|
|
601
|
+
|
|
602
|
+
`Flowy::Pipeline` is an **immutable, composable** pipeline that lives outside any service class. It can be built with a fluent DSL, stored as a constant, passed as a value, composed with `>>`, and embedded inside a `Flowy::Concern`.
|
|
603
|
+
|
|
604
|
+
### Linear pipeline
|
|
605
|
+
|
|
606
|
+
```ruby
|
|
607
|
+
PROCESS = Flowy::Pipeline.new
|
|
608
|
+
.step(:validate) { |prev| ValidateOrder.call(prev.data) }
|
|
609
|
+
.step(:persist) { |prev| PersistOrder.call(prev.data) }
|
|
610
|
+
.step(:notify) { |prev| NotifyUser.call(prev.data) }
|
|
611
|
+
|
|
612
|
+
result = PROCESS.call(starting_data: { order_id: 42 })
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### Symbolic steps — resolved against a `context:`
|
|
616
|
+
|
|
617
|
+
A `step` can also be declared as a bare Symbol without a block. At execution time the method is resolved against the `context:` object passed to `#call`. The resolved method must accept `previous_result:` and return a `Flowy::Result`.
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
PROCESS = Flowy::Pipeline.new
|
|
621
|
+
.step(:validate)
|
|
622
|
+
.step(:persist)
|
|
623
|
+
.step(:notify)
|
|
624
|
+
|
|
625
|
+
class OrderService
|
|
626
|
+
def validate(previous_result:); ...; end
|
|
627
|
+
def persist(previous_result:); ...; end
|
|
628
|
+
def notify(previous_result:); ...; end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
PROCESS.call(starting_data: { order_id: 42 }, context: OrderService.new)
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
Calling a symbolic-step pipeline without a `context:` raises `ArgumentError`. Symbolic and block steps can be freely mixed in the same pipeline.
|
|
635
|
+
|
|
636
|
+
### `tap_step` — side-effects without altering the flow
|
|
637
|
+
|
|
638
|
+
```ruby
|
|
639
|
+
pipeline = Flowy::Pipeline.new
|
|
640
|
+
.step(:persist) { |prev| PersistOrder.call(prev.data) }
|
|
641
|
+
.tap_step(:audit) { |prev| AuditLog.record(prev.data) } # return value is ignored
|
|
642
|
+
.step(:notify) { |prev| NotifyUser.call(prev.data) }
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### Conditional branching
|
|
646
|
+
|
|
647
|
+
Dispatches to a different sub-pipeline based on a key in `previous_result.data` (or the return value of a lambda).
|
|
648
|
+
|
|
649
|
+
#### Dispatch via Symbol key
|
|
650
|
+
|
|
651
|
+
```ruby
|
|
652
|
+
PAYMENT = Flowy::Pipeline.new
|
|
653
|
+
.step(:reserve) { |prev| ReserveStock.call(prev.data) }
|
|
654
|
+
.branch(on: :payment_method) do |b|
|
|
655
|
+
b.when(:stripe) { Flowy::Pipeline.new.step(:charge) { |p| StripeCharge.call(p.data) } }
|
|
656
|
+
b.when(:paypal) { Flowy::Pipeline.new.step(:charge) { |p| PayPalCharge.call(p.data) } }
|
|
657
|
+
b.otherwise { Flowy::Pipeline.new.step(:charge) { |p| DefaultCharge.call(p.data) } }
|
|
658
|
+
end
|
|
659
|
+
.step(:notify) { |prev| NotifyUser.call(prev.data) }
|
|
660
|
+
|
|
661
|
+
result = PAYMENT.call(starting_data: { order_id: 1, payment_method: :stripe })
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
`on: :payment_method` reads `previous_result.data[:payment_method]` and routes to the matching branch. If no branch matches and `otherwise` is not defined, a `Failure` with `error_code: :unmatched_branch` is returned.
|
|
665
|
+
|
|
666
|
+
#### Dispatch via Lambda (arbitrary logic)
|
|
667
|
+
|
|
668
|
+
```ruby
|
|
669
|
+
.branch(on: ->(data) { data[:amount] > 1000 ? :high_value : :standard }) do |b|
|
|
670
|
+
b.when(:high_value) { Flowy::Pipeline.new.step(:premium_flow) { |p| ... } }
|
|
671
|
+
b.when(:standard) { Flowy::Pipeline.new.step(:normal_flow) { |p| ... } }
|
|
672
|
+
end
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### Composition with `>>`
|
|
676
|
+
|
|
677
|
+
Concatenates two or more pipelines into a new immutable pipeline:
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
CHECKOUT = Flowy::Pipeline.new.step(:validate) { ... }.step(:reserve) { ... }
|
|
681
|
+
PAYMENT = Flowy::Pipeline.new.step(:charge) { ... }
|
|
682
|
+
FULFILLMENT = Flowy::Pipeline.new.step(:ship) { ... }.step(:notify) { ... }
|
|
683
|
+
|
|
684
|
+
FULL_ORDER = CHECKOUT >> PAYMENT >> FULFILLMENT
|
|
685
|
+
|
|
686
|
+
result = FULL_ORDER.call(starting_data: { order_id: 42 })
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
### Integration with `Flowy::Concern`
|
|
690
|
+
|
|
691
|
+
A `Flowy::Pipeline` can be used directly as a step, both inline in `run_steps` and in the `.step` DSL:
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
SUB_PIPELINE = Flowy::Pipeline.new
|
|
695
|
+
.step(:enrich) { |prev| EnrichData.call(prev.data) }
|
|
696
|
+
|
|
697
|
+
# Inline
|
|
698
|
+
class OrderService
|
|
699
|
+
include Flowy::Concern
|
|
700
|
+
|
|
701
|
+
def call
|
|
702
|
+
run_steps(
|
|
703
|
+
starting_data: { order_id: 1 },
|
|
704
|
+
steps: [SUB_PIPELINE, :notify]
|
|
705
|
+
)
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# Via DSL
|
|
710
|
+
class OrderService
|
|
711
|
+
include Flowy::Concern
|
|
712
|
+
|
|
713
|
+
step SUB_PIPELINE
|
|
714
|
+
step :notify
|
|
715
|
+
|
|
716
|
+
def call = run_steps(starting_data: { order_id: 1 })
|
|
717
|
+
end
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### Introspection
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
pipeline.steps
|
|
724
|
+
# => [
|
|
725
|
+
# { type: :step, name: :validate },
|
|
726
|
+
# { type: :branch, name: :"branch(payment_method)", on: :payment_method, branches: {...}, otherwise: [...] },
|
|
727
|
+
# { type: :step, name: :notify }
|
|
728
|
+
# ]
|
|
729
|
+
|
|
730
|
+
pipeline.size # => 3
|
|
731
|
+
pipeline.empty? # => false
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### `#call` options
|
|
735
|
+
|
|
736
|
+
| Option | Type | Default | Description |
|
|
737
|
+
|---|---|---|---|
|
|
738
|
+
| `starting_data` | Hash | `{}` | Initial data wrapped in a `Success` |
|
|
739
|
+
| `rescue_errors` | Boolean | `false` | Converts uncaught `StandardError`s into a `Failure` with `error_code: :step_raised_error` |
|
|
740
|
+
| `context` | Object | `nil` | Optional object passed to the block as a second argument (useful when the pipeline is embedded inside a service instance) |
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
744
|
+
## API reference
|
|
745
|
+
|
|
746
|
+
### `Flowy::Success`
|
|
747
|
+
| Method | Description |
|
|
748
|
+
|---|---|
|
|
749
|
+
| `data` | Hash with result payload |
|
|
750
|
+
| `warnings` | Array of warning messages |
|
|
751
|
+
| `success?` | Always `true` |
|
|
752
|
+
| `failure?` | Always `false` |
|
|
753
|
+
| `to_hash` | Serialized result |
|
|
754
|
+
| `+(other)` | Deep-merges two `Success` objects |
|
|
755
|
+
| `on_success` { \|result\| } | Yields `self` and returns `self`; no-op on `Failure` |
|
|
756
|
+
| `on_failure` { } | No-op on `Success`; yields on `Failure` |
|
|
757
|
+
| `and_then` { \|result\| } | Yields `self`, returns the block's result; no-op on `Failure` |
|
|
758
|
+
| `or_else` { } | No-op on `Success`; yields on `Failure` |
|
|
759
|
+
| `merge_data(hash)` / `merge_data { }` | Returns a new `Success` with data deep-merged |
|
|
760
|
+
| `map_failure` / `map_failure(error_code:, ...)` | No-op on `Success` |
|
|
761
|
+
| `raise!` | No-op on `Success`; raises `Flowy::Error` on `Failure` |
|
|
762
|
+
| `tap` { \|result\| } | Yields `self` for side-effects; always returns `self` unchanged |
|
|
763
|
+
|
|
764
|
+
### `Flowy::Failure`
|
|
765
|
+
| Method | Description |
|
|
766
|
+
|---|---|
|
|
767
|
+
| `error_code` | Symbol identifying the error |
|
|
768
|
+
| `error_data` | Hash with contextual error data |
|
|
769
|
+
| `error_title` | Optional human-readable title |
|
|
770
|
+
| `error_description` | Optional human-readable description |
|
|
771
|
+
| `parent_failure` | Optional link to the originating failure |
|
|
772
|
+
| `success?` | Always `false` |
|
|
773
|
+
| `failure?` | Always `true` |
|
|
774
|
+
| `to_hash` | Serialized result |
|
|
775
|
+
| `failures_chain` | Array of chained failures from root to leaf |
|
|
776
|
+
| `is?(error_code:)` | `true` if `self.error_code == error_code` |
|
|
777
|
+
| `on_failure` { \|result\| } | Yields `self` and returns `self`; no-op on `Success` |
|
|
778
|
+
| `on_success` { } | No-op on `Failure`; yields on `Success` |
|
|
779
|
+
| `or_else` { \|result\| } | Yields `self`, returns the block's result; no-op on `Success` |
|
|
780
|
+
| `and_then` { } | No-op on `Failure`; yields on `Success` |
|
|
781
|
+
| `merge_data(hash)` / `merge_data { }` | Returns a new `Failure` with `error_data` deep-merged |
|
|
782
|
+
| `map_failure { \|f\| }` | Transforms `self` into a new `Failure`; sets `parent_failure: self` automatically |
|
|
783
|
+
| `map_failure(error_code:, error_data:, error_title:, error_description:)` | Shorthand — builds the wrapping `Failure` without a block |
|
|
784
|
+
| `raise!` | Raises a `Flowy::Error` built from `self`; no-op on `Success` |
|
|
785
|
+
| `tap` { \|result\| } | Yields `self` for side-effects; always returns `self` unchanged |
|
|
786
|
+
|
|
787
|
+
### `Flowy::Error`
|
|
788
|
+
| Method / attribute | Description |
|
|
789
|
+
|---|---|
|
|
790
|
+
| `code` | Symbol identifying the error (maps to `error_code`) |
|
|
791
|
+
| `title` | Optional human-readable title |
|
|
792
|
+
| `detail` | Optional human-readable description |
|
|
793
|
+
| `meta` | Optional hash with contextual data |
|
|
794
|
+
| `.initialize_from_failure(failure:)` | Builds a `Flowy::Error` from a `Flowy::Failure` |
|
|
795
|
+
| `#to_failure` | Converts back to a `Flowy::Failure` |
|
|
796
|
+
| `#to_hash` | Same structure as `Failure#to_hash` |
|
|
797
|
+
|
|
798
|
+
### `Flowy::Result`
|
|
799
|
+
| Method | Description |
|
|
800
|
+
|---|---|
|
|
801
|
+
| `Result.success(data:, warnings:)` | Factory — builds a `Flowy::Success` |
|
|
802
|
+
| `Result.failure(error_code:, error_data:, error_title:, error_description:, parent_failure:)` | Factory — builds a `Flowy::Failure` |
|
|
803
|
+
| `Result.wrap(rescue:, error_code:, error_title:) { }` | Wraps block outcome; forwards existing result objects unchanged |
|
|
804
|
+
|
|
805
|
+
### `Flowy::Concern`
|
|
806
|
+
|
|
807
|
+
**Instance helpers:**
|
|
808
|
+
|
|
809
|
+
- `success(data:, warnings:)`
|
|
810
|
+
- `failure(error_code:, error_data:, error_title:, error_description:)`
|
|
811
|
+
- `run_steps(starting_data:, steps:, rescue_errors: false)`
|
|
812
|
+
|
|
813
|
+
**Class-level DSL:**
|
|
814
|
+
|
|
815
|
+
| Macro | Description |
|
|
816
|
+
|---|---|
|
|
817
|
+
| `step :name` | Registers a step in the class pipeline |
|
|
818
|
+
| `step :name, rescue: [ExcClass], on_error: :handler` | Step with granular exception handling |
|
|
819
|
+
| `step :name, before_step: :method_or_lambda` | Per-step before-hook (Symbol or callable) |
|
|
820
|
+
| `step :name, after_step: :method_or_lambda` | Per-step after-hook (Symbol or callable) |
|
|
821
|
+
| `step :name, around_step: :method_or_lambda` | Per-step around-hook (Symbol or callable) |
|
|
822
|
+
| `tap_step :name` | Side-effect step; also accepts `before_step:`, `after_step:`, `around_step:` |
|
|
823
|
+
| `before_step { \|step_name, previous_result\| }` | Class-level before-hook (all steps) |
|
|
824
|
+
| `after_step { \|step_name, result\| }` | Class-level after-hook (all steps) |
|
|
825
|
+
| `around_step { \|step_name, previous_result, &call\| }` | Class-level around-hook (all steps) |
|
|
826
|
+
|
|
827
|
+
**Module-level (global) DSL:**
|
|
828
|
+
|
|
829
|
+
| Method | Description |
|
|
830
|
+
|---|---|
|
|
831
|
+
| `Flowy::Concern.before_step { \|step_name, previous_result\| }` | Global before-hook for all service classes |
|
|
832
|
+
| `Flowy::Concern.after_step { \|step_name, result\| }` | Global after-hook for all service classes |
|
|
833
|
+
| `Flowy::Concern.around_step { \|step_name, previous_result, &call\| }` | Global around-hook for all service classes |
|
|
834
|
+
| `Flowy::Concern.clear_global_hooks!` | Removes all global hooks (before, after, around) |
|
|
835
|
+
|
|
836
|
+
**`run_steps` options:**
|
|
837
|
+
|
|
838
|
+
| Option | Type | Default | Description |
|
|
839
|
+
|---|---|---|---|
|
|
840
|
+
| `starting_data` | Hash | `{}` | Initial data wrapped in a `Success` |
|
|
841
|
+
| `steps` | Array\|nil | `nil` | Explicit step list (overrides class DSL when provided) |
|
|
842
|
+
| `rescue_errors` | Boolean | `false` | When `true`, converts uncaught `StandardError`s to a `Failure` with `error_code: :step_raised_error` |
|
|
843
|
+
|
|
844
|
+
**Step method keyword dispatch:**
|
|
845
|
+
|
|
846
|
+
Flowy inspects each Symbol step method's declared keyword parameters and builds the call accordingly:
|
|
847
|
+
|
|
848
|
+
| Parameter type | Behaviour |
|
|
849
|
+
|---|---|
|
|
850
|
+
| `previous_result:` | Receives the full `Flowy::Result` object |
|
|
851
|
+
| `key:` (required, no default) | Resolved from `result.data[key]`; raises `ArgumentError` if the key is absent |
|
|
852
|
+
| `key: default` (optional) | Resolved from `result.data[key]` when present; otherwise Ruby uses the declared default |
|
|
853
|
+
| `**rest` | Receives all remaining data keys not declared explicitly |
|
|
854
|
+
|
|
855
|
+
### `Flowy::Pipeline`
|
|
856
|
+
|
|
857
|
+
| Method | Description |
|
|
858
|
+
|---|---|
|
|
859
|
+
| `#step(name) { \|prev\| }` | Appends a step; the block must return a `Flowy::Result` |
|
|
860
|
+
| `#step(:name)` (no block) | Appends a symbolic step resolved against `context:` at call time; the method must accept `previous_result:` |
|
|
861
|
+
| `#tap_step(name) { \|prev\| }` | Appends a side-effect step; the return value is ignored |
|
|
862
|
+
| `#branch(on:) { \|b\| }` | Appends a branch node; `on:` is a Symbol or Lambda |
|
|
863
|
+
| `#>>(other)` | Composes two pipelines sequentially; returns a new Pipeline |
|
|
864
|
+
| `#call(starting_data:, rescue_errors:, context:)` | Executes the pipeline |
|
|
865
|
+
| `#steps` | Returns the step list for introspection |
|
|
866
|
+
| `#size` | Number of top-level steps (including branch nodes) |
|
|
867
|
+
| `#empty?` | `true` when there are no steps |
|
|
868
|
+
|
|
869
|
+
---
|
|
870
|
+
|
|
871
|
+
## License
|
|
872
|
+
|
|
873
|
+
MIT
|