flow 0.10.5 → 0.10.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +108 -1249
- data/lib/flow.rb +12 -16
- data/lib/flow/errors.rb +14 -0
- data/lib/flow/flow/core.rb +1 -1
- data/lib/flow/flow/trigger.rb +4 -4
- data/lib/flow/flow_base.rb +4 -1
- data/lib/flow/malfunction/base.rb +10 -0
- data/lib/flow/operation/core.rb +1 -1
- data/lib/flow/operation/error_handler.rb +1 -1
- data/lib/flow/operation_base.rb +1 -1
- data/lib/flow/rspec/custom_matchers/have_on_state.rb +6 -1
- data/lib/flow/state_base.rb +4 -1
- data/lib/flow/state_proxy.rb +6 -6
- data/lib/flow/version.rb +1 -1
- metadata +80 -47
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b52c5f113180717df9e242c75b51a80cff0ca0d246007c0adf99247f23137ede
|
4
|
+
data.tar.gz: 51245f2c5609c2788c79fa5a29df9201783c0071a8671f464d42d32b381537a8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bb6a28b1fbed617b3143c2905bc6912a72b1847e54bcbfc69a26856b116a783d79b64455544d103a7c5cdcef85ffab2928aa325ea8c7b1dad5f32055f0657bea
|
7
|
+
data.tar.gz: 38db040a5c7d17b287a46c739f75e16b61861005b9a0ca6b08775083372f9b479620b9f78cefe0cfa1b9cab6f8371cc239e4c6119e1f52dbdc4dbecd3fd6fe8e
|
data/README.md
CHANGED
@@ -1,46 +1,10 @@
|
|
1
1
|
# Flow
|
2
2
|
|
3
3
|
[![Gem Version](https://badge.fury.io/rb/flow.svg)](https://badge.fury.io/rb/flow)
|
4
|
-
[![Build Status](https://semaphoreci.com/api/v1/freshly/flow/branches/
|
4
|
+
[![Build Status](https://semaphoreci.com/api/v1/freshly/flow/branches/main/badge.svg)](https://semaphoreci.com/freshly/flow)
|
5
5
|
[![Maintainability](https://api.codeclimate.com/v1/badges/02131658005b10c289e0/maintainability)](https://codeclimate.com/github/Freshly/flow/maintainability)
|
6
6
|
[![Test Coverage](https://api.codeclimate.com/v1/badges/02131658005b10c289e0/test_coverage)](https://codeclimate.com/github/Freshly/flow/test_coverage)
|
7
7
|
|
8
|
-
* [Installation](#installation)
|
9
|
-
* [Getting Started](#getting-started)
|
10
|
-
* [What is Flow?](#what-is-flow)
|
11
|
-
* [How it Works](#how-it-works)
|
12
|
-
* [Flows](#flows)
|
13
|
-
* [Operations](#operations)
|
14
|
-
* [Accessors](#accessors)
|
15
|
-
* [States](#states)
|
16
|
-
* [Input](#input)
|
17
|
-
* [Output](#output)
|
18
|
-
* [Derivative Data](#derivative-data)
|
19
|
-
* [State Concerns](#state-concerns)
|
20
|
-
* [Validations](#validations)
|
21
|
-
* [Errors](#errors)
|
22
|
-
* [Exceptions](#exceptions)
|
23
|
-
* [Failures](#failures)
|
24
|
-
* [Callback Events](#callback-events)
|
25
|
-
* [Transactions](#transactions)
|
26
|
-
* [Around a Flow](#around-a-flow)
|
27
|
-
* [Around an Operation](#around-an-operation)
|
28
|
-
* [Statuses](#statuses)
|
29
|
-
* [Utilities](#utilities)
|
30
|
-
* [Callbacks](#callbacks)
|
31
|
-
* [Memoization](#memoization)
|
32
|
-
* [Logging](#logging)
|
33
|
-
* [Inheritance](#inheritance)
|
34
|
-
* [Testing](#testing)
|
35
|
-
* [Testing Setup](#testing-setup)
|
36
|
-
* [Testing Flows](#testing-flows)
|
37
|
-
* [Testing Operations](#testing-operations)
|
38
|
-
* [Testing States](#testing-states)
|
39
|
-
* [Integration Testing](#integration-testing)
|
40
|
-
* [Contributing](#contributing)
|
41
|
-
* [Development](#development)
|
42
|
-
* [License](#license)
|
43
|
-
|
44
8
|
## Installation
|
45
9
|
|
46
10
|
Add this line to your application's Gemfile:
|
@@ -56,1314 +20,209 @@ $ bundle install
|
|
56
20
|
$ rails generate flow:install
|
57
21
|
```
|
58
22
|
|
59
|
-
## Getting Started
|
60
|
-
|
61
|
-
Flow comes with some nice rails generators. You are encouraged to use them!
|
62
|
-
|
63
|
-
```bash
|
64
|
-
$ rails generate flow Foo
|
65
|
-
invoke state
|
66
|
-
invoke rspec
|
67
|
-
create spec/states/foo_state_spec.rb
|
68
|
-
create app/states/foo_state.rb
|
69
|
-
invoke rspec
|
70
|
-
create spec/flows/foo_flow_spec.rb
|
71
|
-
create app/flows/foo_flow.rb
|
72
|
-
$ rails generate flow:state Bar
|
73
|
-
invoke rspec
|
74
|
-
create spec/states/bar_state_spec.rb
|
75
|
-
create app/states/bar_state.rb
|
76
|
-
$ rails generate flow:operation MakeTheThingDoTheStuff
|
77
|
-
invoke rspec
|
78
|
-
create spec/operations/make_the_thing_do_the_stuff_spec.rb
|
79
|
-
create app/operations/make_the_thing_do_the_stuff.rb
|
80
|
-
```
|
81
|
-
|
82
23
|
## What is Flow?
|
83
24
|
|
84
25
|
Flow is a [SOLID](https://en.wikipedia.org/wiki/SOLID) implementation of the [Command Pattern](https://en.wikipedia.org/wiki/Command_pattern) for Ruby on Rails.
|
85
26
|
|
86
27
|
Flows allow you to encapsulate your application's [business logic](http://en.wikipedia.org/wiki/Business_logic) into a set of extensible and reusable objects.
|
87
28
|
|
88
|
-
##
|
29
|
+
## Quickstart Example
|
89
30
|
|
90
|
-
|
91
|
-
|
92
|
-
|
31
|
+
Install Flow to your Rails project:
|
32
|
+
```bash
|
33
|
+
$ rails generate flow:install
|
34
|
+
```
|
93
35
|
|
94
|
-
|
36
|
+
Then define `State`, `Operation`(s), and `Flow` objects.
|
95
37
|
|
96
|
-
|
38
|
+
### State
|
97
39
|
|
98
|
-
|
40
|
+
A `State` object defines data that is to be read or written in `Operation` objects throughout the `Flow`. There are several types of data that can be defined, such as `argument`, `option`, and `output`.
|
99
41
|
|
100
|
-
```
|
101
|
-
|
102
|
-
operations ClearExistingTimetables,
|
103
|
-
CalculateTimetables,
|
104
|
-
SummarizeTimetables,
|
105
|
-
DestroyEmptyTimetableCells
|
106
|
-
end
|
42
|
+
```bash
|
43
|
+
$ rails generate flow:state Charge
|
107
44
|
```
|
108
45
|
|
109
|
-
The `operations` are an ordered list of the behaviors which are executed with (and possibly change) the Flow's state.
|
110
|
-
|
111
|
-
Flows accept input representing the arguments and options which define the initial state.
|
112
|
-
|
113
46
|
```ruby
|
114
|
-
|
115
|
-
```
|
47
|
+
# app/states/charge_state.rb
|
116
48
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
49
|
+
class ChargeState < ApplicationState
|
50
|
+
# @!attribute [r]
|
51
|
+
# Order hash, readonly, required
|
52
|
+
argument :order
|
53
|
+
# @!attribute [r]
|
54
|
+
# User model instance readonly, required
|
55
|
+
argument :user
|
124
56
|
|
125
|
-
|
57
|
+
# @!attribute [r]
|
58
|
+
# PaymentMethod model instance readonly, optional
|
59
|
+
option :payment_method
|
126
60
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
MyCoolState
|
131
|
-
end
|
61
|
+
# @!attribute [rw]
|
62
|
+
# Charge model instance readwrite, required
|
63
|
+
output :charge
|
132
64
|
end
|
133
65
|
```
|
134
66
|
|
135
|
-
If you _already have_ an instance of a state class that you want to execute a Flow on, you can simply pass it directly to trigger:
|
136
|
-
|
137
|
-
```ruby
|
138
|
-
state_instance = ExampleState.new(...)
|
139
|
-
|
140
|
-
CalculateTimetablesFlow.trigger(state_instance)
|
141
|
-
```
|
142
|
-
|
143
67
|
### Operations
|
144
68
|
|
145
|
-
|
146
|
-
|
147
|
-
Operations should **not** be named with the `Operation` suffix; name them what they do!
|
148
|
-
|
149
|
-
```ruby
|
150
|
-
class ClearExistingTimetable < ApplicationOperation
|
151
|
-
state_accessor :existing_timetable_cells
|
152
|
-
|
153
|
-
def behavior
|
154
|
-
existing_timetable_cells.update_all(total_minutes: 0)
|
155
|
-
end
|
156
|
-
end
|
157
|
-
```
|
158
|
-
|
159
|
-
```ruby
|
160
|
-
class CalculateTimetables < ApplicationOperation
|
161
|
-
state_accessor :minutes_by_project_employee
|
162
|
-
state_reader :timeframe
|
163
|
-
|
164
|
-
def behavior
|
165
|
-
minutes_by_project_employee.each do |project_employee, total_minutes|
|
166
|
-
project_id, employee_id = project_employee
|
167
|
-
timetable = timeframe.timetables.find_or_create_by!(project_id: project_id)
|
168
|
-
cell = timetable.cells.find_or_create_by!(employee_id: employee_id)
|
169
|
-
|
170
|
-
cell.update!(total_minutes: total_minutes)
|
171
|
-
end
|
172
|
-
end
|
173
|
-
end
|
174
|
-
```
|
69
|
+
`Operation` objects execute some procedure defined in a `#behavior` method and can read and write to `State` data via defined accessor methods.
|
175
70
|
|
176
|
-
```
|
177
|
-
|
178
|
-
state_accessor :timetables
|
179
|
-
|
180
|
-
def behavior
|
181
|
-
timetables.each do |timetable|
|
182
|
-
timetable.update!(total_minutes: timetable.cells.sum(:total_minutes))
|
183
|
-
end
|
184
|
-
end
|
185
|
-
end
|
71
|
+
```bash
|
72
|
+
$ rails generate flow:operation CreateCharge
|
186
73
|
```
|
187
74
|
|
188
75
|
```ruby
|
189
|
-
|
190
|
-
state_accessor :empty_cells
|
191
|
-
|
192
|
-
def behavior
|
193
|
-
empty_cells.destroy_all
|
194
|
-
end
|
195
|
-
end
|
196
|
-
```
|
197
|
-
|
198
|
-
Operations take a state as input and define a `#behavior` that occurs when `#execute` is called.
|
76
|
+
# app/operations/create_charge.rb
|
199
77
|
|
200
|
-
|
78
|
+
class CreateCharge < ApplicationOperation
|
79
|
+
# @!attribute [r]
|
80
|
+
# Order hash, readonly
|
81
|
+
state_reader :order
|
82
|
+
# @!attribute [r]
|
83
|
+
# User model instance readonly
|
84
|
+
state_reader :user
|
85
|
+
# @!attribute [r]
|
86
|
+
# PaymentMethod model instance readonly
|
87
|
+
state_reader :payment_method
|
201
88
|
|
202
|
-
|
203
|
-
|
204
|
-
|
89
|
+
# @!attribute [rw]
|
90
|
+
# Charge model instance readwrite
|
91
|
+
state_writer :charge
|
205
92
|
|
206
93
|
def behavior
|
207
|
-
|
94
|
+
state.charge = Charge.create(payment_method: payment_method, order: order, user: user)
|
208
95
|
end
|
209
|
-
end
|
210
|
-
|
211
|
-
operation = ExampleOperation.new(OpenStruct.new(first_name: "Eric"))
|
212
|
-
operation.execute
|
213
|
-
# Hello, Eric
|
214
|
-
operation.executed? # => true
|
215
|
-
```
|
216
|
-
|
217
|
-
⚠️ **Deprecation Warning**: Direct state access in Operations is being removed. [Learn more](./DEPRECATION_NOTICE.md)
|
218
|
-
|
219
|
-
#### Accessors
|
220
|
-
|
221
|
-
**Operations** define, through *State Accessors*, what data they will read from and/or write to a **State**.
|
222
|
-
|
223
|
-
These accessors provide an explicit means to declare input needs and output expectations.
|
224
|
-
|
225
|
-
```ruby
|
226
|
-
class ExampleOperation < ApplicationOperation
|
227
|
-
state_reader :foo
|
228
|
-
state_writer :bar
|
229
|
-
state_accessor :baz
|
230
|
-
end
|
231
|
-
```
|
232
96
|
|
233
|
-
|
234
|
-
|
235
|
-
⚠️ *Warning*: Your Operation will not be able to access methods on your State if you do not declare them using these accessors!
|
236
|
-
|
237
|
-
The following are considered best practices for working with state accessors:
|
238
|
-
|
239
|
-
`state_reader` should **only** read data and not alter it
|
240
|
-
|
241
|
-
```ruby
|
242
|
-
# Bad, don't do it this way:
|
243
|
-
class BadOperation < ApplicationOperation
|
244
|
-
state_reader :foo
|
245
|
-
|
246
|
-
def behavior
|
247
|
-
foo << :more_dataz
|
248
|
-
end
|
249
|
-
end
|
97
|
+
private
|
250
98
|
|
251
|
-
|
252
|
-
|
253
|
-
state_reader :foo
|
254
|
-
|
255
|
-
def behavior
|
256
|
-
SomeThirdParty.do_a_thing if foo == :foo
|
99
|
+
def payment_method
|
100
|
+
payment_method.present? payment_method : user.default_payment_method
|
257
101
|
end
|
258
102
|
end
|
259
103
|
```
|
260
104
|
|
261
|
-
|
262
|
-
|
263
|
-
```ruby
|
264
|
-
class ExampleOperation < ApplicationOperation
|
265
|
-
state_writer :foo
|
266
|
-
|
267
|
-
def behavior
|
268
|
-
state.foo = :foo
|
269
|
-
end
|
270
|
-
end
|
271
|
-
|
272
|
-
# Bad, don't do it this way:
|
273
|
-
class BadState < ApplicationState
|
274
|
-
option :foo
|
275
|
-
end
|
105
|
+
Use failure methods when an `Operation` and `Flow` should fail and no longer run:
|
276
106
|
|
277
|
-
|
278
|
-
|
279
|
-
output :foo
|
280
|
-
end
|
107
|
+
```bash
|
108
|
+
$ rails generate flow:operation SubmitCharge
|
281
109
|
```
|
282
110
|
|
283
|
-
`state_accessor` should be used rather than defining both setters and getters:
|
284
|
-
|
285
111
|
```ruby
|
286
|
-
#
|
287
|
-
class BadOperation < ApplicationOperation
|
288
|
-
state_reader :foo
|
289
|
-
state_writer :foo
|
290
|
-
|
291
|
-
def behavior
|
292
|
-
state.foo = :bar if foo == :baz
|
293
|
-
end
|
294
|
-
end
|
295
|
-
|
296
|
-
# Good, do it this way:
|
297
|
-
class GoodOperation < ApplicationOperation
|
298
|
-
state_accessor :foo
|
299
|
-
|
300
|
-
def behavior
|
301
|
-
state.foo = :bar if foo == :baz
|
302
|
-
end
|
303
|
-
end
|
304
|
-
```
|
112
|
+
# app/operations/submit_charge.rb
|
305
113
|
|
306
|
-
|
114
|
+
class SubmitCharge < ApplicationOperation
|
115
|
+
# @!method charge_unsuccessful_failure!(data = {})
|
116
|
+
# Raises, stops the Operation and Flow, takes unstructured data hash
|
117
|
+
failure :charge_unsuccessful
|
307
118
|
|
308
|
-
|
309
|
-
#
|
310
|
-
|
311
|
-
state_reader :foo
|
312
|
-
|
313
|
-
def behavior
|
314
|
-
foo << :more_dataz
|
315
|
-
end
|
316
|
-
end
|
119
|
+
# @!attribute [rw]
|
120
|
+
# Charge model instance read only
|
121
|
+
state_reader :charge
|
317
122
|
|
318
|
-
# Good, do it this way:
|
319
|
-
class GoodOperation < ApplicationOperation
|
320
|
-
state_accessor :foo
|
321
|
-
|
322
123
|
def behavior
|
323
|
-
|
324
|
-
end
|
325
|
-
end
|
326
|
-
```
|
327
|
-
|
328
|
-
### States
|
329
|
-
|
330
|
-
A **State** is an aggregation of input and derived data.
|
331
|
-
|
332
|
-
All States should be named with the `State` suffix (ex: `FooState`).
|
333
|
-
|
334
|
-
```ruby
|
335
|
-
class CalculateTimetablesState < ApplicationState
|
336
|
-
argument :timeframe
|
124
|
+
charge_unsuccessful_failure!(response_body: response.body) unless success?
|
337
125
|
|
338
|
-
|
339
|
-
@existing_timetable_cells ||= TimetableCell.where(timetable: existing_timetables)
|
340
|
-
end
|
341
|
-
|
342
|
-
def minutes_by_project_employee
|
343
|
-
@minutes_by_project_employee ||= data_by_employee_project.transform_values do |values|
|
344
|
-
values.sum(&:total_minutes)
|
345
|
-
end
|
346
|
-
end
|
347
|
-
|
348
|
-
def timetables
|
349
|
-
@timetables ||= Timetable.where(project_id: project_ids)
|
350
|
-
end
|
351
|
-
|
352
|
-
def empty_cells
|
353
|
-
@empty_cells ||= TimetableCell.
|
354
|
-
joins(:timetable).
|
355
|
-
where(total_minutes: 0, timetables: { project_id: project_ids })
|
126
|
+
charge.update(success: true)
|
356
127
|
end
|
357
128
|
|
358
129
|
private
|
359
130
|
|
360
|
-
|
361
|
-
|
362
|
-
def existing_timetables
|
363
|
-
@existing_timetables ||= timeframe.timetables.where(project_id: project_ids)
|
364
|
-
end
|
365
|
-
|
366
|
-
def project_ids
|
367
|
-
@project_ids ||= timesheet_data.map(&:project_id).uniq
|
368
|
-
end
|
369
|
-
|
370
|
-
def data_by_employee_project
|
371
|
-
@data_by_employee_project ||= timesheet_data.group_by do |data|
|
372
|
-
[ data.project_id, data.employee_id ]
|
373
|
-
end
|
131
|
+
def success?
|
132
|
+
response.body.success == "true"
|
374
133
|
end
|
375
134
|
|
376
|
-
def
|
377
|
-
|
378
|
-
reportable.
|
379
|
-
summarizable.
|
380
|
-
joins(:timeclock).
|
381
|
-
select("timeclocks.project_id, timeclocks.employee_id, timesheets.total_minutes")
|
135
|
+
def response
|
136
|
+
PaymentProcessorClient.submit_charge(charge)
|
382
137
|
end
|
138
|
+
memoize :response
|
383
139
|
end
|
384
140
|
```
|
385
141
|
|
386
|
-
|
387
|
-
|
388
|
-
A state accepts input represented by **arguments** and **options** which initialize it.
|
389
|
-
|
390
|
-
**Arguments** describe input required to define the initial state.
|
391
|
-
|
392
|
-
If any arguments are missing, an `ArgumentError` is raised.
|
393
|
-
|
394
|
-
```ruby
|
395
|
-
class ExampleFlow < ApplicationFlow; end
|
396
|
-
class ExampleState < ApplicationState
|
397
|
-
argument :foo
|
398
|
-
argument :bar
|
399
|
-
end
|
400
|
-
|
401
|
-
ExampleFlow.trigger # => ArgumentError (Missing arguments: foo, bar)
|
402
|
-
ExampleFlow.trigger(foo: :foo) # => ArgumentError (Missing argument: bar)
|
403
|
-
ExampleFlow.trigger(foo: :foo, bar: :bar) # => #<ExampleFlow:0x00007ff7b7d92ae0 ...>
|
404
|
-
```
|
405
|
-
|
406
|
-
By default, nil is a valid argument:
|
407
|
-
|
408
|
-
```ruby
|
409
|
-
ExampleFlow.trigger(foo: nil, bar: nil) # => #<ExampleFlow:0x10007ff7b7d92ae0 ...>
|
410
|
-
```
|
411
|
-
|
412
|
-
If you want to require a non-nil value for your argument, set the `allow_nil` option (`true` by default):
|
413
|
-
|
414
|
-
```ruby
|
415
|
-
class ExampleState < ApplicationState
|
416
|
-
argument :foo
|
417
|
-
argument :bar, allow_nil: false
|
418
|
-
end
|
419
|
-
|
420
|
-
ExampleFlow.trigger(foo: nil, bar: nil) # => ArgumentError (Missing argument: bar)
|
421
|
-
```
|
422
|
-
|
423
|
-
**Options** describe input which may be provided to define or override the initial state.
|
424
|
-
|
425
|
-
Options can optionally define a default value.
|
426
|
-
|
427
|
-
If no default is specified, the value will be `nil`.
|
428
|
-
|
429
|
-
If the default value is static, it can be specified in the class definition.
|
430
|
-
|
431
|
-
If the default value is dynamic, you may provide a block to compute the default value.
|
432
|
-
|
433
|
-
⚠️ **Heads Up**: The default value blocks **DO NOT** provide access to the state or its other variables!
|
434
|
-
|
435
|
-
```ruby
|
436
|
-
class ExampleFlow < ApplicationFlow; end
|
437
|
-
class ExampleState < ApplicationState
|
438
|
-
option :attribution_source
|
439
|
-
option :favorite_foods, default: %w[pizza ice_cream gluten]
|
440
|
-
option(:favorite_color) { SecureRandom.hex(3) }
|
441
|
-
end
|
442
|
-
|
443
|
-
result = ExampleFlow.trigger(favorite_foods: %w[avocado hummus nutritional_yeast])
|
444
|
-
state = result.state
|
445
|
-
|
446
|
-
state.attribution_source # => nil
|
447
|
-
state.favorite_color # => "1a1f1e"
|
448
|
-
state.favorite_foods # => ["avocado", "hummus" ,"nutritional_yeast"]
|
449
|
-
```
|
450
|
-
|
451
|
-
#### Output
|
452
|
-
|
453
|
-
Output data is created by Operations during runtime and CANNOT be validated or provided as part of the input. It can only be written once the state has been validated successfully, otherwise an error is raised.
|
454
|
-
|
455
|
-
```ruby
|
456
|
-
class ExampleState < ApplicationState
|
457
|
-
argument :name
|
458
|
-
|
459
|
-
validates :name, length: { minimum: 3 }
|
460
|
-
output :foo
|
461
|
-
end
|
462
|
-
|
463
|
-
state = ExampleState.new(name: "fe")
|
464
|
-
state.foo # => raises Flow::State::Errors::NotValidated
|
465
|
-
state.foo = :something # => raises Flow::State::Errors::NotValidated
|
142
|
+
### Flow
|
466
143
|
|
467
|
-
|
468
|
-
state.foo # => raises Flow::State::Errors::NotValidated
|
469
|
-
state.foo = :something # => raises Flow::State::Errors::NotValidated
|
144
|
+
A `Flow` object is composed of one or more ordered `Operation`s. Changes to the state will persist from one `Operation` to the next:
|
470
145
|
|
471
|
-
|
472
|
-
|
473
|
-
state.foo # => nil
|
474
|
-
state.foo = :something # => :something
|
475
|
-
state.outputs.foo # :something
|
146
|
+
```bash
|
147
|
+
$ rails generate flow Charge
|
476
148
|
```
|
477
149
|
|
478
|
-
Outputs can optionally define a default value.
|
479
|
-
|
480
|
-
If no default is specified, the value will be `nil`.
|
481
|
-
|
482
|
-
If the default value is static, it can be specified in the class definition.
|
483
|
-
|
484
|
-
If the default value is dynamic, you may provide a block to compute the default value.
|
485
|
-
|
486
|
-
⚠️ **Heads Up**: The default value blocks **DO NOT** provide access to the state or it's other variables!
|
487
|
-
|
488
150
|
```ruby
|
489
|
-
|
490
|
-
output :story, default: []
|
491
|
-
end
|
492
|
-
|
493
|
-
class AskAQuestion < ApplicationOperation
|
494
|
-
def behavior
|
495
|
-
state.story << "Bah Bah, Black Sheep. Have you any wool?"
|
496
|
-
end
|
497
|
-
end
|
498
|
-
|
499
|
-
class GiveAnAnswer < ApplicationOperation
|
500
|
-
def behavior
|
501
|
-
state.story << "Yes sir, yes sir! Three bags full!"
|
502
|
-
end
|
503
|
-
end
|
151
|
+
# app/flow/charge_flow.rb
|
504
152
|
|
505
|
-
class
|
506
|
-
operations
|
153
|
+
class ChargeFlow < ApplicationFlow
|
154
|
+
operations CreateCharge,
|
155
|
+
SubmitCharge
|
507
156
|
end
|
508
|
-
|
509
|
-
result = ExampleFlow.trigger
|
510
|
-
result.outputs.story.join("\n")
|
511
|
-
# Bah Bah, Black Sheep. Have you any wool?
|
512
|
-
# Yes sir, yes sir! Three bags full!
|
513
157
|
```
|
514
158
|
|
515
|
-
|
159
|
+
### Usage
|
516
160
|
|
517
|
-
|
161
|
+
Trigger the `Flow` in your code with `State` inputs:
|
518
162
|
|
519
163
|
```ruby
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
164
|
+
flow_input = {
|
165
|
+
order: order,
|
166
|
+
user: current_user,
|
167
|
+
payment_method: visa_credit_card,
|
168
|
+
}
|
524
169
|
|
525
|
-
|
526
|
-
def behavior
|
527
|
-
state.foo = Bar.where(foo: foo)
|
528
|
-
end
|
529
|
-
end
|
170
|
+
flow = ChargeFlow.trigger(flow_input)
|
530
171
|
```
|
531
172
|
|
532
|
-
|
533
|
-
|
534
|
-
```ruby
|
535
|
-
class GoodState < ApplicationState
|
536
|
-
argument :foo
|
537
|
-
|
538
|
-
output :gaz
|
539
|
-
|
540
|
-
def bar
|
541
|
-
Bar.where(foo: foo)
|
542
|
-
end
|
543
|
-
memoize :bar
|
544
|
-
end
|
173
|
+
Arguments defined on `State` are required when triggering a `Flow`, options are optional:
|
545
174
|
|
546
|
-
class GoodOperation < ApplicationOperation
|
547
|
-
def behavior
|
548
|
-
state.gaz = Gaz.create!(name: foo.name, count: bar.count)
|
549
|
-
end
|
550
|
-
end
|
551
175
|
```
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
If you want to define an explicit results payload, do so explicitly:
|
556
|
-
|
557
|
-
```ruby
|
558
|
-
class GoodState < ApplicationState
|
559
|
-
argument :foo
|
560
|
-
option :bar, default: :nar
|
561
|
-
output :gaz
|
562
|
-
|
563
|
-
def results
|
564
|
-
outputs.to_h.dup.merge(foo: foo, bar: bar)
|
565
|
-
end
|
566
|
-
end
|
176
|
+
> ChargeFlow.trigger({})
|
177
|
+
ArgumentError: Missing arguments: order, user
|
567
178
|
```
|
568
179
|
|
569
|
-
|
570
|
-
|
571
|
-
States provide you with a clear place to put any logic related to pre-processing of data.
|
180
|
+
State output can be accessed from the flow instance:
|
572
181
|
|
573
|
-
They also can help give developers a clear picture of how your data fits together:
|
574
|
-
|
575
|
-
```ruby
|
576
|
-
class ExampleState < ApplicationState
|
577
|
-
argument :user
|
578
|
-
|
579
|
-
def most_actionable_order
|
580
|
-
editable_orders.order(ship_date: :desc).first
|
581
|
-
end
|
582
|
-
|
583
|
-
private
|
584
|
-
|
585
|
-
def editable_orders
|
586
|
-
user.orders.unshipped.paid
|
587
|
-
end
|
588
|
-
end
|
589
182
|
```
|
183
|
+
> flow = ChargeFlow.trigger(flow_input)
|
590
184
|
|
591
|
-
|
592
|
-
|
593
|
-
The architecture of each Flow having it's own state introduces a code reuse constraint.
|
594
|
-
|
595
|
-
Consider the following example:
|
596
|
-
|
597
|
-
```ruby
|
598
|
-
class MyExampleState < ApplicationState
|
599
|
-
argument :user
|
600
|
-
|
601
|
-
def most_actionable_order
|
602
|
-
editable_orders.order(ship_date: :desc).first
|
603
|
-
end
|
604
|
-
|
605
|
-
private
|
606
|
-
|
607
|
-
def editable_orders
|
608
|
-
user.orders.unshipped.paid
|
609
|
-
end
|
610
|
-
end
|
611
|
-
|
612
|
-
class MyOtherExampleState < ApplicationState
|
613
|
-
argument :user
|
614
|
-
|
615
|
-
def least_actionable_order
|
616
|
-
editable_orders.order(ship_date: :desc).last
|
617
|
-
end
|
618
|
-
|
619
|
-
private
|
620
|
-
|
621
|
-
def editable_orders
|
622
|
-
user.orders.unshipped.paid
|
623
|
-
end
|
624
|
-
end
|
185
|
+
> flow.state.charge
|
186
|
+
=> #<Charge:0x00007fd5c5cda080 ... >
|
625
187
|
```
|
626
188
|
|
627
|
-
|
189
|
+
Success of the triggered `Flow` can be determined with these methods:
|
628
190
|
|
629
|
-
For example, we could create `app/states/concerns/actionable_user_orders.rb`:
|
630
|
-
|
631
|
-
```ruby
|
632
|
-
module ActionableUserOrders
|
633
|
-
extend ActiveSupport::Concern
|
634
|
-
|
635
|
-
included do
|
636
|
-
argument :user
|
637
|
-
end
|
638
|
-
|
639
|
-
private
|
640
|
-
|
641
|
-
def orders_by_ship_data
|
642
|
-
editable_orders.order(ship_date: :desc)
|
643
|
-
end
|
644
|
-
|
645
|
-
def editable_orders
|
646
|
-
user.orders.unshipped.paid
|
647
|
-
end
|
648
|
-
end
|
649
191
|
```
|
192
|
+
> flow.success?
|
193
|
+
=> true
|
650
194
|
|
651
|
-
|
652
|
-
|
653
|
-
```ruby
|
654
|
-
class MyExampleState < ApplicationState
|
655
|
-
include ActionableUserOrders
|
656
|
-
|
657
|
-
def most_actionable_order
|
658
|
-
orders_by_ship_data.first
|
659
|
-
end
|
660
|
-
end
|
661
|
-
|
662
|
-
class MyOtherExampleState < ApplicationState
|
663
|
-
include ActionableUserOrders
|
664
|
-
|
665
|
-
def least_actionable_order
|
666
|
-
orders_by_ship_data.last
|
667
|
-
end
|
668
|
-
end
|
195
|
+
> flow.failed?
|
196
|
+
=> false
|
669
197
|
```
|
670
198
|
|
671
|
-
|
672
|
-
|
673
|
-
States are ActiveModels which means they have access to [ActiveModel::Validations](https://api.rubyonrails.org/classes/ActiveModel/Validations.html).
|
199
|
+
If the `Flow` fails you can see the failures on the instance:
|
674
200
|
|
675
|
-
It is considered a best practice to write validations in your states.
|
676
|
-
|
677
|
-
Flows which have an invalid state will NOT execute any Operations, so it is inherently the safest and clearest way to proactively communicate about missed expectations.
|
678
|
-
|
679
|
-
💁 **Pro Tip**: There is a `trigger!` method on Flows that will raise certain errors that are normally silenced. Invalid states are one such example!
|
680
|
-
|
681
|
-
```ruby
|
682
|
-
class ExampleFlow < ApplicationFlow; end
|
683
|
-
class ExampleState < ApplicationState
|
684
|
-
argument :first_name
|
685
|
-
|
686
|
-
validates :first_name, length: { minimum: 2 }
|
687
|
-
end
|
688
|
-
|
689
|
-
ExampleFlow.trigger!(first_name: "a") # => raises Flow::Errors::StateInvalid
|
690
|
-
|
691
|
-
result = ExampleFlow.trigger(first_name: "a")
|
692
|
-
result.success? # => false
|
693
|
-
result.failed? # => false
|
694
|
-
result.triggered? # => false
|
695
|
-
result.state.errors.messages # => {:first_name=>["is too short (minimum is 2 characters)"]}
|
696
201
|
```
|
202
|
+
# some flow that results in a failure...
|
203
|
+
> flow = ChargeFlow.trigger(flow_input)
|
697
204
|
|
698
|
-
|
699
|
-
|
700
|
-
![Flow Errors](docs/images/error.png)
|
701
|
-
|
702
|
-
When `#execute` is unsuccessful, expected problems are **failures** and unexpected problems are **Exceptions**.
|
703
|
-
|
704
|
-
Errors handling can be either either **proactive** or **reactive**; ideally all errors that can be are *proactive*.
|
705
|
-
|
706
|
-
**Proactive** error handling is a form of defensive programming. Instead of letting an error occur, you fail with a very clear signal as to why. Explicit failures are more desirable than than letting unexpected behavior dictate the program flow.
|
707
|
-
|
708
|
-
**Reactive** error handling should be used to handle areas of the code where you do not control the underlying behaviors, such as integrations with third party gems. When you know something you can't prevent could happen, you can define a reactive error handler to cleanly translate an *exception* into a *failure*.
|
709
|
-
|
710
|
-
### Exceptions
|
711
|
-
|
712
|
-
When an exception is raised during during execution, but a handler can rescue, it causes a failure instead.
|
713
|
-
|
714
|
-
Otherwise, an unhandled exception will raise through both the Operation and Flow.
|
715
|
-
`
|
716
|
-
```ruby
|
717
|
-
class ExampleState < ApplicationState
|
718
|
-
argument :number
|
719
|
-
end
|
720
|
-
|
721
|
-
class ExampleOperation < ApplicationOperation
|
722
|
-
handle_error RuntimeError
|
723
|
-
|
724
|
-
def behavior
|
725
|
-
raise (state.number % 2 == 0 ? StandardError : RuntimeError)
|
726
|
-
end
|
727
|
-
end
|
728
|
-
|
729
|
-
class ExampleFlow < ApplicationFlow
|
730
|
-
operations ExampleOperation
|
731
|
-
end
|
205
|
+
> flow.success?
|
206
|
+
=> false
|
732
207
|
|
733
|
-
|
734
|
-
|
735
|
-
|
208
|
+
# get the failure
|
209
|
+
> flow.operation_failure.problem
|
210
|
+
=> :charge_unsuccessful
|
736
211
|
|
737
|
-
|
738
|
-
operation_failure.
|
739
|
-
|
212
|
+
# access unstructured hash passed into failure method
|
213
|
+
> flow.operation_failure.details.response_body
|
214
|
+
=> { some_response_body_here: ... }
|
740
215
|
```
|
741
216
|
|
742
|
-
|
743
|
-
|
744
|
-
If no problem is specified explicitly, a demodulized underscored version of the error is used.
|
745
|
-
|
746
|
-
```ruby
|
747
|
-
class ExampleOperation < ApplicationOperation
|
748
|
-
handle_error RuntimeError, problem: :something_bad_happened
|
749
|
-
handle_error ActiveRecord::RecordInvalid
|
750
|
-
|
751
|
-
def behavior
|
752
|
-
raise (state.number % 2 == 0 ? ActiveRecord::RecordInvalid : RuntimeError)
|
753
|
-
end
|
754
|
-
end
|
755
|
-
|
756
|
-
result0 = ExampleFlow.trigger(number: 0)
|
757
|
-
operation_failure = result0.failed_operation.operation_failure
|
758
|
-
operation_failure.problem # => :record_invalid
|
759
|
-
operation_failure.details.exception # => #<ActiveRecord::RecordInvalid: Record invalid>
|
760
|
-
|
761
|
-
result1 = ExampleFlow.trigger(number: 1)
|
762
|
-
result1.failed_operation.operation_failure.problem # => :something_bad_happened
|
763
|
-
```
|
764
|
-
|
765
|
-
You can also provide handlers in the form of either a block or a method name:
|
766
|
-
|
767
|
-
```ruby
|
768
|
-
class ExampleOperation < ApplicationOperation
|
769
|
-
handle_error RuntimeError, with: :handle_some_error
|
770
|
-
handle_error ActiveRecord::RecordInvalid do
|
771
|
-
# Do something here
|
772
|
-
end
|
773
|
-
|
774
|
-
def behavior
|
775
|
-
raise (state.number % 2 == 0 ? ActiveRecord::RecordInvalid : RuntimeError)
|
776
|
-
end
|
777
|
-
|
778
|
-
private
|
779
|
-
|
780
|
-
def handle_some_error
|
781
|
-
# Do something different here
|
782
|
-
end
|
783
|
-
end
|
784
|
-
```
|
785
|
-
|
786
|
-
### Failures
|
787
|
-
|
788
|
-
In theory, failures should *never* occur in your Flows. Any guard clause you can put inside of an Operation to proactively fail you should be able to put inside of the state as a validation.
|
789
|
-
|
790
|
-
In practice, failures will *always* occur in your Flows. Any sufficiently large organization will receive contributions from developers of all skill and business-specific knowledge levels. The suggested use of one State class per Flow means that if every state is responsible for proactive validation, you will eventually have a misstep and forget to include it.
|
791
|
-
|
792
|
-
Having your Operation proactively fail is an example of [contract programming](https://en.wikipedia.org/wiki/Design_by_contract) and provides developers with a clear and non-brittle expectation of how it should be used.
|
793
|
-
|
794
|
-
From a conceptual standpoint, you should consider your Operations as the most atomic expression of your business logic. Flows, and (by extension) the States that support them, are most effective when built up around a well defined set of Operations.
|
795
|
-
|
796
|
-
When your system has multiple consistent ways to defend against corrupt data or prevent executions that generate exceptions, it's robust not redundant.
|
797
|
-
|
798
|
-
`</rant>`
|
799
|
-
|
800
|
-
Failures are part of the class definition of your Operation.
|
801
|
-
|
802
|
-
```ruby
|
803
|
-
class PassBottlesAround < ApplicationOperation
|
804
|
-
failure :too_generous
|
805
|
-
|
806
|
-
def behavior
|
807
|
-
too_generous_failure! if state.number_to_take_down >= 4
|
808
|
-
end
|
809
|
-
end
|
810
|
-
```
|
811
|
-
|
812
|
-
When you define a failure a `#{failure_name}_failure!` method is defined for you.
|
813
|
-
|
814
|
-
Calling this `_failure!` method will raise an exception which Flow handles by default, meaning it will not be raised as an exception from the Flow.
|
815
|
-
|
816
|
-
An unstructured hash of data can be provided to the `_failure!` method and will be available in the `operation_failure` object:
|
817
|
-
|
818
|
-
```ruby
|
819
|
-
class PassBottlesAround < ApplicationOperation
|
820
|
-
failure :too_generous
|
821
|
-
|
822
|
-
def behavior
|
823
|
-
if state.number_to_take_down >= 4
|
824
|
-
disappointment_level = state.number_to_take_down >= 10 ? :wow_very_disappoint : :am_disappoint
|
825
|
-
too_generous_failure!(disappointment_level: disappointment_level)
|
826
|
-
end
|
827
|
-
end
|
828
|
-
end
|
829
|
-
|
830
|
-
result5 = ExampleFlow.trigger(number_to_take_down: 5)
|
831
|
-
operation_failure5 = result5.failed_operation.operation_failure
|
832
|
-
operation_failure5.problem # => :too_generous
|
833
|
-
operation_failure5.details.disappointment_level # => :am_disappoint
|
834
|
-
|
835
|
-
result11 = ExampleFlow.trigger(number_to_take_down: 11)
|
836
|
-
operation_failure11 = result11.failed_operation.operation_failure
|
837
|
-
operation_failure11.problem # => :too_generous
|
838
|
-
operation_failure11.details.disappointment_level # => :wow_very_disappoint
|
839
|
-
```
|
840
|
-
|
841
|
-
You can also specify `if:` / `unless:` options to proactively trigger failures as guard clauses:
|
842
|
-
|
843
|
-
```ruby
|
844
|
-
class PassBottlesAround < ApplicationOperation
|
845
|
-
failure :too_dangerous, if: -> { state.bottle_of == "tequila" }
|
846
|
-
failure :not_dangerous_enough, unless: :drink_dangerous?
|
847
|
-
|
848
|
-
private
|
849
|
-
|
850
|
-
def drink_dangerous?
|
851
|
-
%[water juice soda].exclude? state.bottle_of
|
852
|
-
end
|
853
|
-
end
|
854
|
-
```
|
855
|
-
|
856
|
-
### Callback Events
|
857
|
-
|
858
|
-
Operations feature error events which are triggered when a problem occurs.
|
859
|
-
|
860
|
-
This works for explictly defined failures:
|
861
|
-
|
862
|
-
```ruby
|
863
|
-
class OperationOne < ApplicationOperation
|
864
|
-
failure :too_greedy
|
865
|
-
|
866
|
-
on_too_greedy_failure do
|
867
|
-
SlackClient.send_message(:engineering, "Someones trying to give away too much stuff!")
|
868
|
-
end
|
869
|
-
end
|
870
|
-
```
|
871
|
-
|
872
|
-
As well as manually handled errors (using the demodulized underscored name of the error):
|
873
|
-
|
874
|
-
```ruby
|
875
|
-
class OperationTwo < ApplicationOperation
|
876
|
-
handle_error ActiveRecord::RecordInvalid
|
877
|
-
|
878
|
-
on_record_invalid_failure do
|
879
|
-
Redis.incr("operation_two:invalid_records")
|
880
|
-
end
|
881
|
-
end
|
882
|
-
```
|
883
|
-
|
884
|
-
You can also listen for any problems using the generic failure event:
|
885
|
-
|
886
|
-
```ruby
|
887
|
-
class OperationThree < ApplicationOperation
|
888
|
-
handle_error RuntimeError
|
889
|
-
|
890
|
-
on_failure do
|
891
|
-
EngineeringMailer.on_runtime_error(self.class.name)
|
892
|
-
end
|
893
|
-
end
|
894
|
-
```
|
895
|
-
|
896
|
-
## Transactions
|
897
|
-
|
898
|
-
![Flow Transactions](docs/images/transaction.png)
|
899
|
-
|
900
|
-
Flow features a callback driven approach to wrap business logic within database transaction.
|
901
|
-
|
902
|
-
Both **Flows** and **Operations** can be wrapped with a transaction.
|
903
|
-
|
904
|
-
### Around a Flow
|
905
|
-
|
906
|
-
Flows where no operation should be persisted unless all are successful should use a transaction.
|
907
|
-
|
908
|
-
```ruby
|
909
|
-
class ExampleFlow < ApplicationFlow
|
910
|
-
wrap_in_transaction
|
911
|
-
|
912
|
-
operations OperationOne, OperationTwo, OperationThree
|
913
|
-
end
|
914
|
-
```
|
915
|
-
|
916
|
-
### Around an Operation
|
917
|
-
|
918
|
-
Operations which modify several persisted objects together should use a transaction.
|
919
|
-
|
920
|
-
```ruby
|
921
|
-
class OperationTwo < ApplicationFlow
|
922
|
-
wrap_in_transaction
|
923
|
-
|
924
|
-
def behavior
|
925
|
-
# do a thing
|
926
|
-
end
|
927
|
-
end
|
928
|
-
```
|
929
|
-
|
930
|
-
## Statuses
|
931
|
-
|
932
|
-
Flows, Operations, and States all have a set of predicate methods to describe their current status.
|
933
|
-
|
934
|
-
### Flows
|
935
|
-
|
936
|
-
| Status | Description |
|
937
|
-
| ------------ | ------------------------- |
|
938
|
-
| `pending?` | `#trigger` not called. |
|
939
|
-
| `triggered?` | `#trigger` was called. |
|
940
|
-
| `failed?` | Some operation failed. |
|
941
|
-
| `success?` | All operations succeeded. |
|
942
|
-
|
943
|
-
### Operations
|
944
|
-
|
945
|
-
| Status | Description |
|
946
|
-
| ------------ | ------------------------- |
|
947
|
-
| `executed?` | `#execute` was called. |
|
948
|
-
| `failed?` | Execution failed. |
|
949
|
-
| `success?` | Execution succeeded. |
|
950
|
-
|
951
|
-
### States
|
952
|
-
|
953
|
-
| Status | Description |
|
954
|
-
| ------------ | ------------------------- |
|
955
|
-
| `validated?` | `#vaild?` returned true. |
|
956
|
-
|
957
|
-
## Utilities
|
958
|
-
|
959
|
-
Flow offers a number of utilities which allow you to tap into and extend it's functionality.
|
960
|
-
|
961
|
-
### Callbacks
|
962
|
-
|
963
|
-
Flows, Operations, and States all make use of [ActiveSupport::Callbacks](https://api.rubyonrails.org/classes/ActiveSupport/Callbacks.html) to compose advanced functionality.
|
964
|
-
|
965
|
-
```ruby
|
966
|
-
class TakeBottlesDown < ApplicationOperation
|
967
|
-
set_callback(:execute, :before) { bottle_count_term }
|
968
|
-
set_callback(:execute, :after) { state.output.push("You take #{bottle_count_term} down.") }
|
969
|
-
|
970
|
-
def bottle_count_term
|
971
|
-
return "it" if state.bottles.number_on_the_wall == 1
|
972
|
-
return "one" if state.taking_down_one?
|
973
|
-
|
974
|
-
state.number_to_take_down
|
975
|
-
end
|
976
|
-
end
|
977
|
-
```
|
978
|
-
|
979
|
-
Please consult the `ActiveSupport::Callbacks` documentation for guidance on how to use them.
|
980
|
-
|
981
|
-
The callbacks which are available on each class are:
|
982
|
-
|
983
|
-
| Class Name | Callbacks | Fired When... |
|
984
|
-
| ------------- | ------------- | -------------------------------------- |
|
985
|
-
| Flow | `:initialize` | When a new flow is being constructed. |
|
986
|
-
| Flow | `:trigger` | When `#trigger` is called on a flow. |
|
987
|
-
| Flow | `:flux` | When `#trigger` is called on a flow. |
|
988
|
-
| State | `:initialize` | When a new state is being constructed. |
|
989
|
-
| Operation | `:execute` | When `#execute` is called. |
|
990
|
-
| Operation | `:behavior` | When `#execute` is called. |
|
991
|
-
| Operation | `:failure` | When any type of error occurs. |
|
992
|
-
| Operation | `$problem` | When an error of type $problem occurs. |
|
993
|
-
|
994
|
-
### Memoization
|
995
|
-
|
996
|
-
Flow includes the very awesome [ShortCircuIt](https://github.com/Freshly/spicerack/tree/develop/short_circu_it) gem.
|
997
|
-
|
998
|
-
To leverage it, just add `memoize :method_name` to your Flows, Operations, or States.
|
999
|
-
|
1000
|
-
```ruby
|
1001
|
-
class TakeBottlesDown < ApplicationOperation
|
1002
|
-
def bottle_count_term
|
1003
|
-
return "it" if state.bottles.number_on_the_wall == 1
|
1004
|
-
return "one" if state.taking_down_one?
|
1005
|
-
|
1006
|
-
state.number_to_take_down
|
1007
|
-
end
|
1008
|
-
memoize :bottle_count_term
|
1009
|
-
end
|
1010
|
-
```
|
1011
|
-
|
1012
|
-
Consult the documentation for `ShorCircuIt` for more info on how to use it.
|
1013
|
-
|
1014
|
-
### Logging
|
1015
|
-
|
1016
|
-
Flow includes the [Technologic](https://github.com/Freshly/spicerack/tree/develop/technologic) gem.
|
1017
|
-
|
1018
|
-
The gems adds methods to Flows, Operations, and States which share names with log levels.
|
1019
|
-
|
1020
|
-
| Level | Used For |
|
1021
|
-
| ------- | -------------------------------------- |
|
1022
|
-
| `debug` | Extra data; usually off in production. |
|
1023
|
-
| `info` | Standard data you always want to have. |
|
1024
|
-
| `warn` | Unexpected (but not exceptional) data. |
|
1025
|
-
| `error` | Exceptional cases representing issues. |
|
1026
|
-
| `fatal` | Highly actionable and critical issues. |
|
1027
|
-
|
1028
|
-
```ruby
|
1029
|
-
class ExampleOperation < ApplicationOperation
|
1030
|
-
def behavior
|
1031
|
-
warn(:nothing_to_do, { empty_object: obj }) and return if obj.empty?
|
1032
|
-
|
1033
|
-
debug(:doing_a_thing)
|
1034
|
-
|
1035
|
-
results = do_thing
|
1036
|
-
|
1037
|
-
log(:did_a_thing, results: results)
|
1038
|
-
end
|
1039
|
-
end
|
1040
|
-
```
|
1041
|
-
|
1042
|
-
Flows and States come with automated out-of-the-box logging.
|
1043
|
-
|
1044
|
-
The following is an example of what is logged without any extra log lines:
|
1045
|
-
|
1046
|
-
```text
|
1047
|
-
I, [2019-03-06T12:31:06.008329 #25951] INFO -- : {:event=>"trigger_started.CalculateWorksheetsFlow"}
|
1048
|
-
I, [2019-03-06T12:31:06.008551 #25951] INFO -- : {:event=>"execute_started.AssignCommitsToWorksheet"}
|
1049
|
-
I, [2019-03-06T12:31:07.402005 #25951] INFO -- : {:event=>"execute_finished.AssignCommitsToWorksheet", :duration=>1.393346}
|
1050
|
-
I, [2019-03-06T12:31:07.402217 #25951] INFO -- : {:event=>"execute_started.AssignCommentsToWorksheet"}
|
1051
|
-
I, [2019-03-06T12:31:07.438144 #25951] INFO -- : {:event=>"execute_finished.AssignCommentsToWorksheet"}
|
1052
|
-
I, [2019-03-06T12:31:07.438235 #25951] INFO -- : {:event=>"trigger_finished.CalculateWorksheetsFlow", :duration=>1.429788}
|
1053
|
-
```
|
1054
|
-
|
1055
|
-
Consult the documentation for `Technologic` for more info on how to use it.
|
1056
|
-
|
1057
|
-
## Inheritance
|
1058
|
-
|
1059
|
-
Flows, Operations, and States all support inheritance of class definitions.
|
1060
|
-
|
1061
|
-
```ruby
|
1062
|
-
class ParentState < ApplicationState
|
1063
|
-
argument :foo
|
1064
|
-
end
|
1065
|
-
|
1066
|
-
class ChildState < ParentState
|
1067
|
-
argument :bar
|
1068
|
-
end
|
1069
|
-
|
1070
|
-
class ChildFlow < ApplicationFlow; end
|
1071
|
-
ChildFlow.trigger(bar: :bar) # => ArgumentError (Missing argument: foo)
|
1072
|
-
```
|
1073
|
-
|
1074
|
-
A common pattern in Flow is to use inheritance to DRY and conceptually related flows.
|
1075
|
-
|
1076
|
-
Take for example the case of `Calculation` and `Recalculation`
|
1077
|
-
|
1078
|
-
```ruby
|
1079
|
-
class CalculateFooFlow < ApplicationFlow
|
1080
|
-
operations ClearOldFooCalculations, CaclulcateFoo, EmailFooReport
|
1081
|
-
end
|
1082
|
-
|
1083
|
-
class CalculateFooState < ApplicationState
|
1084
|
-
def foos
|
1085
|
-
Foo.where(calculated_at: nil)
|
1086
|
-
end
|
1087
|
-
end
|
1088
|
-
|
1089
|
-
# =================
|
1090
|
-
|
1091
|
-
class RecalculateFooFlow < CalculateFooFlow; end
|
1092
|
-
class RecalculateFooState < ApplicationState
|
1093
|
-
def foos
|
1094
|
-
Foo.all
|
1095
|
-
end
|
1096
|
-
end
|
1097
|
-
```
|
1098
|
-
|
1099
|
-
There communicates that there is no difference between the Flows other than their States!
|
1100
|
-
|
1101
|
-
## Testing
|
1102
|
-
|
1103
|
-
If you plan on writing `RSpec` tests `Flow` comes packaged with some custom matchers.
|
1104
|
-
|
1105
|
-
### Testing Setup
|
1106
|
-
|
1107
|
-
Add the following to your `spec/rails_helper.rb` file:
|
1108
|
-
|
1109
|
-
```ruby
|
1110
|
-
require "flow/spec_helper"
|
1111
|
-
```
|
1112
|
-
|
1113
|
-
Flow works best with [shoulda-matchers](https://github.com/thoughtbot/shoulda-matchers) and [rspice](https://github.com/Freshly/spicerack/tree/develop/rspice).
|
1114
|
-
|
1115
|
-
Add those to the `development` and `test` group of your Gemfile:
|
1116
|
-
|
1117
|
-
```ruby
|
1118
|
-
group :development, :test do
|
1119
|
-
gem "shoulda-matchers", git: "https://github.com/thoughtbot/shoulda-matchers.git", branch: "rails-5"
|
1120
|
-
gem "rspice"
|
1121
|
-
end
|
1122
|
-
```
|
1123
|
-
|
1124
|
-
Then run `bundle install` and add the following into `spec/rails_helper.rb`:
|
1125
|
-
|
1126
|
-
```ruby
|
1127
|
-
require "rspec/rails"
|
1128
|
-
require "rspice"
|
1129
|
-
require "flow/spec_helper"
|
1130
|
-
|
1131
|
-
# Configuration for the shoulda-matchers gem
|
1132
|
-
Shoulda::Matchers.configure do |config|
|
1133
|
-
config.integrate do |with|
|
1134
|
-
with.test_framework :rspec
|
1135
|
-
with.library :rails
|
1136
|
-
end
|
1137
|
-
end
|
1138
|
-
```
|
1139
|
-
|
1140
|
-
This will allow you to use the following custom matchers:
|
1141
|
-
* [define_argument](lib/flow/custom_matchers/define_argument.rb) tests usage of `ApplicationState.argument`
|
1142
|
-
* [define_attribute](lib/flow/custom_matchers/define_attribute.rb) tests usage of `ApplicationState.attribute`
|
1143
|
-
* [define_failure](lib/flow/custom_matchers/define_failure.rb) tests usage of `ApplicationOperation.failure`
|
1144
|
-
* [define_option](lib/flow/custom_matchers/define_option.rb) tests usage of `ApplicationState.option`
|
1145
|
-
* [define_output](lib/flow/custom_matchers/define_output.rb) tests usage of `ApplicationState.output`
|
1146
|
-
* [handle_error](lib/flow/custom_matchers/handle_error.rb) tests usage of `ApplicationOperation.handle_error`
|
1147
|
-
* [use_operations](lib/flow/custom_matchers/use_operations.rb) tests usage of `ApplicationFlow.operations`
|
1148
|
-
* [wrap_in_transaction](lib/flow/custom_matchers/wrap_in_transaction.rb) tests usage of `.wrap_in_transaction` for `ApplicationFlow` or `ApplicationOperation`
|
1149
|
-
* [have_on_state](lib/flow/custom_matchers/have_on_state.rb) tests for data on State after a `ApplicationFlow` or `ApplicationOperation` has been run
|
1150
|
-
* [access_state](lib/flow/custom_matchers/access_state.rb) tests usage of `ApplicationOperation.state_accessor` (or use of both `state_(reader|writer)`)
|
1151
|
-
* [read_state](lib/flow/custom_matchers/read_state.rb) tests usage of `ApplicationOperation.state_reader`
|
1152
|
-
* [write_state](lib/flow/custom_matchers/write_state.rb) tests usage of `ApplicationOperation.state_writer`
|
1153
|
-
|
1154
|
-
### Testing Flows
|
1155
|
-
|
1156
|
-
The best way to test a Flow is with an integration test.
|
1157
|
-
|
1158
|
-
The easiest way to test a Flow is with a unit test.
|
1159
|
-
|
1160
|
-
Flow are generated with the following RSPec template:
|
1161
|
-
|
1162
|
-
```ruby
|
1163
|
-
# frozen_string_literal: true
|
1164
|
-
|
1165
|
-
require "rails_helper"
|
1166
|
-
|
1167
|
-
RSpec.describe FooFlow, type: :flow do
|
1168
|
-
subject(:flow) { described_class.new(**input) }
|
1169
|
-
|
1170
|
-
let(:input) do
|
1171
|
-
{}
|
1172
|
-
end
|
1173
|
-
|
1174
|
-
it { is_expected.to inherit_from ApplicationFlow }
|
1175
|
-
# it { is_expected.to use_operations ExampleOperation }
|
1176
|
-
|
1177
|
-
describe "#trigger" do
|
1178
|
-
subject(:trigger) { flow.trigger! }
|
1179
|
-
|
1180
|
-
pending "describe the effects of a successful `Flow#flux` (or delete) #{__FILE__}"
|
1181
|
-
end
|
1182
|
-
end
|
1183
|
-
```
|
1184
|
-
|
1185
|
-
### Testing Operations
|
1186
|
-
|
1187
|
-
The easiest and best way to test an Operation is with a unit test.
|
1188
|
-
|
1189
|
-
Operation unit tests work best when you treat them like integration tests! (Read: **No Mocking!**)
|
1190
|
-
|
1191
|
-
Operations are generated with the following RSpec template:
|
1192
|
-
|
1193
|
-
```ruby
|
1194
|
-
# frozen_string_literal: true
|
1195
|
-
|
1196
|
-
require "rails_helper"
|
1197
|
-
|
1198
|
-
RSpec.describe MakeTheThingDoTheStuff, type: :operation do
|
1199
|
-
subject(:operation) { described_class.new(state) }
|
1200
|
-
|
1201
|
-
let(:state) { example_state_class.new(**state_input).tap(&:validate) }
|
1202
|
-
let(:example_state_class) do
|
1203
|
-
Class.new(ApplicationState) do
|
1204
|
-
# argument :foo
|
1205
|
-
|
1206
|
-
# output :gaz
|
1207
|
-
end
|
1208
|
-
end
|
1209
|
-
let(:state_input) do
|
1210
|
-
{}
|
1211
|
-
end
|
1212
|
-
|
1213
|
-
it { is_expected.to inherit_from ApplicationOperation }
|
1214
|
-
|
1215
|
-
# it { is_expected.to access_state :foo }
|
1216
|
-
# it { is_expected.to read_state :bar }
|
1217
|
-
# it { is_expected.to write_state :baz }
|
1218
|
-
|
1219
|
-
describe "#execute" do
|
1220
|
-
subject(:execute) { operation.execute }
|
1221
|
-
|
1222
|
-
pending "describe `Operation#behavior` (or delete) #{__FILE__}"
|
1223
|
-
end
|
1224
|
-
end
|
1225
|
-
```
|
1226
|
-
|
1227
|
-
⚠️ *Warning*: You have to do a little work to write a good test state!
|
1228
|
-
|
1229
|
-
In the boilerplate from the generator, there is the following snippet:
|
1230
|
-
|
1231
|
-
```ruby
|
1232
|
-
let(:example_state_class) do
|
1233
|
-
Class.new(ApplicationState) do
|
1234
|
-
# argument :foo
|
1235
|
-
|
1236
|
-
# output :gaz
|
1237
|
-
end
|
1238
|
-
end
|
1239
|
-
```
|
1240
|
-
|
1241
|
-
By default, your operation specs are broken! The reason for this is to encourage resilient test writing.
|
1242
|
-
|
1243
|
-
Let's say that you have an Operation in your system called `CreateFoo` which is part of the `CreateFooFlow` and therefore is only ever called with a `CreateFooState`. You may be tempted to write something like:
|
1244
|
-
|
1245
|
-
```ruby
|
1246
|
-
let(:example_state_class) { CreateFooState }
|
1247
|
-
```
|
1248
|
-
|
1249
|
-
You are heavily encouraged *not* to do that. If your Operation is used by several different Flows, you don't want to have your test arbitrarily using some state for the test.
|
1250
|
-
|
1251
|
-
Instead, use the spec as a way to communicate the contract of the Operation with the next developer. By boiling out a very clean example state that only includes what is necessary for the operation, you provide clear guidance on what the Operation's minimum requirements for a state are in a very transparent way.
|
1252
|
-
|
1253
|
-
However, the emphasis is on _minimum_ requirements. `Option`s and `attribute`s are therefore discouraged in example states for operation specs, as well are contexts for when optional input is not provided. An operation designed for use with a State using optional inputs will _always_ require those methods to be available on its state class, so example states should always use arguments to define input.
|
1254
|
-
|
1255
|
-
```ruby
|
1256
|
-
let(:example_state_class) do
|
1257
|
-
Class.new(ApplicationState) do
|
1258
|
-
argument :foo
|
1259
|
-
argument :bar
|
1260
|
-
|
1261
|
-
output :gaz
|
1262
|
-
end
|
1263
|
-
|
1264
|
-
let(:state_input) do
|
1265
|
-
{ foo: foo }
|
1266
|
-
end
|
1267
|
-
|
1268
|
-
let(:foo) { ... }
|
1269
|
-
let(:bar) { nil }
|
1270
|
-
end
|
1271
|
-
```
|
1272
|
-
|
1273
|
-
You are encouraged to use `execute`, rather than `execute!` in testing. You can trust that if an `operation_failure` is present, the operation _would_ have raised an `Operation::Failures::OperationFailure` if you used `execute!`.
|
1274
|
-
|
1275
|
-
Doing so will allow you to make assertions on the failure without having to expect errors:
|
1276
|
-
```ruby
|
1277
|
-
class SomeOperation < ApplicationOperation
|
1278
|
-
state_reader :foo
|
1279
|
-
|
1280
|
-
failure :somethings_invalid
|
1281
|
-
|
1282
|
-
def behavior
|
1283
|
-
somethings_invalid_failure! baz: "relevant data" if foo == "something invalid"
|
1284
|
-
end
|
1285
|
-
end
|
1286
|
-
```
|
1287
|
-
|
1288
|
-
```ruby
|
1289
|
-
let(:state_input) { foo: "something invalid" }
|
1290
|
-
|
1291
|
-
before { operation.execute }
|
1292
|
-
|
1293
|
-
it "fails with the expected data" do
|
1294
|
-
expect(operation.operation_failure.problem).to eq :somethings_invalid
|
1295
|
-
expect(operation.operation_failure.details.baz).to eq "relevant data"
|
1296
|
-
end
|
1297
|
-
```
|
1298
|
-
|
1299
|
-
### Testing States
|
1300
|
-
|
1301
|
-
The easiest and best way to test a State is with a unit test.
|
1302
|
-
|
1303
|
-
States are generated with the following RSPec template:
|
1304
|
-
|
1305
|
-
```ruby
|
1306
|
-
# frozen_string_literal: true
|
1307
|
-
|
1308
|
-
require "rails_helper"
|
1309
|
-
|
1310
|
-
RSpec.describe FooState, type: :state do
|
1311
|
-
subject(:state) { described_class }
|
1312
|
-
|
1313
|
-
it { is_expected.to inherit_from ApplicationState }
|
1314
|
-
# it { is_expected.to define_argument :required_input }
|
1315
|
-
# it { is_expected.to define_argument :necessary_input, allow_nil: false }
|
1316
|
-
# it { is_expected.to define_option(:optional_input) }
|
1317
|
-
# it { is_expected.to define_option(:option_with_default, default: :default_static_value) }
|
1318
|
-
|
1319
|
-
# let(:default_block_value) { SecureRandom.uuid }
|
1320
|
-
# before { allow(SecureRandom).to receive(:uuid).and_return(default_block_value) }
|
1321
|
-
# it { is_expected.to define_option(:option_with_default_from_block, default: default_block_value) }
|
1322
|
-
|
1323
|
-
# it { is_expected.to validate_presence_of ... }
|
1324
|
-
# it { is_expected.to define_output :foo }
|
1325
|
-
# it { is_expected.to define_output :foo, default: :bar }
|
1326
|
-
end
|
1327
|
-
```
|
1328
|
-
|
1329
|
-
💡 **Reminder**: You need to install `shoulda-matchers` to use things like `.to validate_presence_of ...`, `rspice` for `.to inherit_from ...`, and the `flow/spec_helper.rb` for `define_argument` and the like.
|
1330
|
-
|
1331
|
-
### Integration Testing
|
1332
|
-
|
1333
|
-
The best integration tests are the simplest!
|
1334
|
-
|
1335
|
-
Create a state of the universe and confirm your flow changes it.
|
1336
|
-
|
1337
|
-
```ruby
|
1338
|
-
describe "integration test" do
|
1339
|
-
subject(:flow) { ChangePreferencesFlow.trigger(user: user, favorite_food: new_favorite_food) }
|
1340
|
-
|
1341
|
-
let(:user) { create :user, favorite_food: original_favorite_food }
|
1342
|
-
let(:original_favorite_food) { Faker::Lorem.unique.word }
|
1343
|
-
let(:new_favorite_food) { Faker::Lorem.unique.word }
|
1344
|
-
|
1345
|
-
it "changes User#favorite_food" do
|
1346
|
-
expect { flow }.
|
1347
|
-
to change { user.favorite_food }.
|
1348
|
-
from(original_favorite_food).
|
1349
|
-
to(new_favorite_food)
|
1350
|
-
end
|
1351
|
-
end
|
1352
|
-
```
|
1353
|
-
|
1354
|
-
💡 *Note*: It's considered a best practice to put your integration test in the same file as the unit test.
|
1355
|
-
|
1356
|
-
And always remember: **Good integration tests don't use mocking**!
|
1357
|
-
|
1358
|
-
## Contributing
|
217
|
+
## Wiki
|
1359
218
|
|
1360
|
-
|
219
|
+
Learn more with our wiki [Getting Started](https://github.com/Freshly/flow/wiki/Getting-Started#installation) page.
|
1361
220
|
|
1362
|
-
|
221
|
+
You also can download wiki to have offline access.
|
222
|
+
Just simply do:
|
1363
223
|
|
1364
|
-
|
224
|
+
`git clone git@github.com:Freshly/flow.wiki.git`
|
1365
225
|
|
1366
|
-
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
1367
226
|
|
1368
227
|
## License
|
1369
228
|
|