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