smash_the_state 1.2.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +422 -0
- data/Rakefile +21 -0
- data/lib/smash_the_state.rb +7 -0
- data/lib/smash_the_state/matchers.rb +56 -0
- data/lib/smash_the_state/operation.rb +111 -0
- data/lib/smash_the_state/operation/definition.rb +37 -0
- data/lib/smash_the_state/operation/dry_run.rb +75 -0
- data/lib/smash_the_state/operation/error.rb +19 -0
- data/lib/smash_the_state/operation/sequence.rb +116 -0
- data/lib/smash_the_state/operation/state.rb +104 -0
- data/lib/smash_the_state/operation/state_type.rb +19 -0
- data/lib/smash_the_state/operation/step.rb +21 -0
- data/lib/smash_the_state/version.rb +3 -0
- data/spec/unit/operation/definition_spec.rb +57 -0
- data/spec/unit/operation/sequence_spec.rb +256 -0
- data/spec/unit/operation/state_spec.rb +143 -0
- data/spec/unit/operation/step_spec.rb +30 -0
- data/spec/unit/operation_spec.rb +493 -0
- metadata +90 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: afd9065697aa4494f7ccf324fd1322f0a44c7dd99495c772766f7b1ad63070b2
|
4
|
+
data.tar.gz: 7a58038b3b8f06b9d21871a5321d627224f7c56f65b3d3bbcb7d40b54af3b802
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6305468c38c4f46fdfda9e38134896b405e57f425afad55bada899b0e2071659eaa005eed4e0851708765d8da36c834ac97ba3e7698cf0c2855e8f699808dd4c
|
7
|
+
data.tar.gz: 91cc65e7186915376b8990b2efc40093573a8dbc16039177786ba20b524a1c3b95713c9b0d0eb733cdbfe7ea66cdf22f5ce7dc22dcd61675aedb21855e005017
|
data/README.md
ADDED
@@ -0,0 +1,422 @@
|
|
1
|
+
[![Build Status](https://travis.ibm.com/compose/smash_the_state.svg?token=jqswnXsg6LbeRHSEXA1p&branch=master)](https://travis.ibm.com/compose/smash_the_state)
|
2
|
+
|
3
|
+
# Smash the State
|
4
|
+
A useful utility for transforming state that provides step sequencing, middleware, and validation.
|
5
|
+
|
6
|
+
# Example
|
7
|
+
|
8
|
+
``` ruby
|
9
|
+
class CreateUserOperation < SmashTheState::Operation
|
10
|
+
# the schema defines how state is initialized. state begins life as a light-weight
|
11
|
+
# struct with the keys defined below and values type-cast using
|
12
|
+
# https://github.com/Azdaroth/active_model_attributes
|
13
|
+
schema do
|
14
|
+
attribute :email, :string
|
15
|
+
attribute :name, :string
|
16
|
+
attribute :age, :integer
|
17
|
+
end
|
18
|
+
|
19
|
+
# if a validate block is provided, a validation step will be inserted at its position in
|
20
|
+
# the class relative to the other steps. failing validation will cause the operation to
|
21
|
+
# exit, returning the current state
|
22
|
+
validate do
|
23
|
+
validates_presence_of :name, :email
|
24
|
+
end
|
25
|
+
|
26
|
+
# steps are executed in the order in which they are defined when the operation is
|
27
|
+
# run. each step is expected to return the new state of the operation, which is handed
|
28
|
+
# to the subsequent step
|
29
|
+
step :normalize_data do |state|
|
30
|
+
state.tap do |state|
|
31
|
+
state.email = state.email.downcase
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
custom_validation do |state|
|
36
|
+
state.errors.add(:email, "no funny bizness") if state.email.end_with? ".biz"
|
37
|
+
end
|
38
|
+
|
39
|
+
# this step receives the normalized state from :normalize_data and creates a user,
|
40
|
+
# which, because it is returned from the block, becomes the new operation state
|
41
|
+
step :create_user do |state|
|
42
|
+
User.create(state.as_json)
|
43
|
+
end
|
44
|
+
|
45
|
+
# this step receives the created user as its state and sends the user an email
|
46
|
+
step :send_email do |user|
|
47
|
+
email_job = Email.send(user.email, "hi there!")
|
48
|
+
error!(user) if email_job.failed?
|
49
|
+
user
|
50
|
+
end
|
51
|
+
|
52
|
+
# this error handler is attached to the :send_email step. if `error!`` is called
|
53
|
+
# in a step, the error handler attached to the step is executed with the state as its
|
54
|
+
# argument. an optional, second error argument may be passed in when error! is called.
|
55
|
+
# if an error handler is run, operation execution halts, subsequent steps are not
|
56
|
+
# executed, and the return value of the error handler block is returned to the caller
|
57
|
+
error :send_email do |user|
|
58
|
+
Logger.error "sending an email to #{user.email} failed"
|
59
|
+
# do some error handling here
|
60
|
+
user
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
```
|
65
|
+
|
66
|
+
# Advanced stuff
|
67
|
+
|
68
|
+
## Original state
|
69
|
+
|
70
|
+
If the state changes from step-to-step, don't I lose track of my original state? What if I change my state to something else but I need to access the original operation input data?
|
71
|
+
|
72
|
+
Each step not only receives the state of the previous step, but also a copy of the original, unmolested state object that was formed from the union of the input params and the schema. To access the original state data, access it as the second argument in your step block.
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
class CreateAnalyticsOperation < SmashTheState::Operation
|
76
|
+
schema do
|
77
|
+
attribute :action, :string
|
78
|
+
attribute :request_ip, :string
|
79
|
+
attribute :private_note, :string
|
80
|
+
end
|
81
|
+
|
82
|
+
step :create_analytics do |state|
|
83
|
+
Analytics.create(action: state.action, request_ip: state.request_ip)
|
84
|
+
end
|
85
|
+
|
86
|
+
step :send_private_note do |state, original_state|
|
87
|
+
# so state is an Analytics instance here. what if we want the original
|
88
|
+
# state? 'state' is no longer our schema-generated state object, but that
|
89
|
+
# data is available as 'original_state'. we can send our private note while
|
90
|
+
# still forwarding along the analytics state to the next step
|
91
|
+
PrivateNote.create(body: original_state.private_note)
|
92
|
+
|
93
|
+
# pass the Analytics state down to the next step
|
94
|
+
state
|
95
|
+
end
|
96
|
+
end
|
97
|
+
```
|
98
|
+
|
99
|
+
## Dynamic State Classes (built at runtime)
|
100
|
+
|
101
|
+
Maybe your operation needs a more flexible schema than a static state class can provide. If you need your state class to be evaluated at runtime, you can omit a schema block and the raw params will be passed in as the initial state. From there you can create whatever state class you desire from inside the first step.
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
class MyOperation < SmashTheState::Operation
|
105
|
+
step :custom_state_class do |params|
|
106
|
+
c = Operation::State.build do
|
107
|
+
# create whatever state class you need at runtime
|
108
|
+
attribute :some_name, :some_type
|
109
|
+
end
|
110
|
+
|
111
|
+
c.new(params)
|
112
|
+
end
|
113
|
+
|
114
|
+
step :do_more_things do |state, params|
|
115
|
+
# params will be the initial params
|
116
|
+
# ... and so on
|
117
|
+
end
|
118
|
+
end
|
119
|
+
```
|
120
|
+
|
121
|
+
## Middleware
|
122
|
+
|
123
|
+
You can define middleware classes to which the operation can delegate steps. The middleware class names can be arbitrarily composed by information pulled from the state.
|
124
|
+
|
125
|
+
Let's say you have two database types: `WhiskeyDB` and `AbsintheDB`, each of which have different behaviors in the `:create_environment` step. You can delegate those behaviors using middleware.
|
126
|
+
|
127
|
+
``` ruby
|
128
|
+
class WhiskeyDBCreateMiddleware
|
129
|
+
def create_environment(state)
|
130
|
+
# do whiskey things
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
```
|
135
|
+
|
136
|
+
``` ruby
|
137
|
+
class AbsintheDBCreateMiddleware
|
138
|
+
def create_environment(state)
|
139
|
+
# do absinthe things
|
140
|
+
end
|
141
|
+
end
|
142
|
+
```
|
143
|
+
|
144
|
+
``` ruby
|
145
|
+
class CreateDatabase < SmashTheState::Operation
|
146
|
+
schema do
|
147
|
+
attribute :name, :string
|
148
|
+
end
|
149
|
+
|
150
|
+
middleware_class do |state|
|
151
|
+
"#{state.name}CreateMiddleware"
|
152
|
+
end
|
153
|
+
|
154
|
+
middleware_step :create_environment
|
155
|
+
|
156
|
+
step :more_things do |state_from_middleware_step|
|
157
|
+
# ... and so on
|
158
|
+
end
|
159
|
+
end
|
160
|
+
```
|
161
|
+
|
162
|
+
## Representation
|
163
|
+
|
164
|
+
Let's say you want to represent the state of the operation, wrapped in a class that defines some `as_*` and `to_*` methods. You can do this with a `represent` step.
|
165
|
+
|
166
|
+
``` ruby
|
167
|
+
class DatabaseRepresenter
|
168
|
+
def initialize(state)
|
169
|
+
@state = state
|
170
|
+
end
|
171
|
+
|
172
|
+
def as_json
|
173
|
+
{name: @state.name, foo: "bar"} # and so on
|
174
|
+
end
|
175
|
+
|
176
|
+
def as_xml
|
177
|
+
XML.dump(@state)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
```
|
181
|
+
|
182
|
+
``` ruby
|
183
|
+
class CreateDatabaseOperation < SmashTheState::Operation
|
184
|
+
schema do
|
185
|
+
attribute :name, :string
|
186
|
+
end
|
187
|
+
|
188
|
+
# ... steps
|
189
|
+
|
190
|
+
represent DatabaseRepresenter
|
191
|
+
end
|
192
|
+
```
|
193
|
+
|
194
|
+
``` ruby
|
195
|
+
> CreateDatabaseOperation.call(name: "AbsintheDB").as_json
|
196
|
+
=> {name: "AbsintheDB", foo: "bar"}
|
197
|
+
```
|
198
|
+
|
199
|
+
## Policy
|
200
|
+
|
201
|
+
[Pundit](https://github.com/elabs/pundit) style policies are supported via a `policy` method. Failing policies raise a `SmashTheState::Operation::NotAuthorized` exception and run at the position in the sequence in which they are defined. Pass `current_user` into your operation alongside your params.
|
202
|
+
|
203
|
+
``` ruby
|
204
|
+
class DatabasePolicy
|
205
|
+
attr_reader :current_user, :database
|
206
|
+
|
207
|
+
def initialize(current_user, database)
|
208
|
+
@current_user = current_user
|
209
|
+
@database = database
|
210
|
+
end
|
211
|
+
|
212
|
+
def allowed?
|
213
|
+
@current_user.age > 21
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
class CreateDatabaseOperation < SmashTheState::Operation
|
218
|
+
schema do
|
219
|
+
attribute :type, :string
|
220
|
+
# ...
|
221
|
+
end
|
222
|
+
|
223
|
+
step :get_database do |state|
|
224
|
+
Database.find_by_type(type: state.type)
|
225
|
+
end
|
226
|
+
|
227
|
+
# state is now a database and is passed into DatabasePolicy as "database",
|
228
|
+
# while current_user is passed in as "current_user"
|
229
|
+
policy DatabasePolicy, :allowed?
|
230
|
+
end
|
231
|
+
```
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
CreateDatabaseOperation.call(
|
235
|
+
current_user: some_user,
|
236
|
+
type: "WhiskeyDB"
|
237
|
+
# ... and so on
|
238
|
+
)
|
239
|
+
```
|
240
|
+
|
241
|
+
The `NotAuthorized` exception will also provide the failing policy instance via a `policy_instance` method so that you can reason about what exactly went wrong.
|
242
|
+
|
243
|
+
```ruby
|
244
|
+
begin
|
245
|
+
CreateDatabaseOperation.call(
|
246
|
+
current_user: some_user,
|
247
|
+
type: "WhiskeyDB"
|
248
|
+
# ... and so on
|
249
|
+
)
|
250
|
+
rescue SmashTheState::Operation::NotAuthorized => e
|
251
|
+
e.policy_instance.current_user == some_user # true
|
252
|
+
e.policy_instance.database.is_a? Database # also true
|
253
|
+
end
|
254
|
+
```
|
255
|
+
|
256
|
+
## Continuation
|
257
|
+
|
258
|
+
Smash the State operations are functional, so they don't support inheritance. In lieu of inheritance, you may use `continues_from`, which frontloads an existing operation in front of the operation being defined. It feeds the state result of the first operation into the first step of the second.
|
259
|
+
|
260
|
+
``` ruby
|
261
|
+
class SecondOperation < SmashTheState::Operation
|
262
|
+
continues_from FirstOperation
|
263
|
+
|
264
|
+
step :another_step do |state|
|
265
|
+
# ... continue to smash the state
|
266
|
+
end
|
267
|
+
end
|
268
|
+
```
|
269
|
+
|
270
|
+
## Nested State Schemas
|
271
|
+
|
272
|
+
While it's best to keep things simple, sometimes things are complex. As such, you may nest state schemas like so:
|
273
|
+
|
274
|
+
```ruby
|
275
|
+
class Database::Create < Compose::Operation
|
276
|
+
schema do
|
277
|
+
attribute :type, :string
|
278
|
+
|
279
|
+
schema :host do
|
280
|
+
attribute :name, :string
|
281
|
+
|
282
|
+
schema :resources do
|
283
|
+
attribute :ram_units, :integer
|
284
|
+
attribute :cpu_units, :integer
|
285
|
+
end
|
286
|
+
|
287
|
+
# nest as many layers deep as you like
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
step :allocate_cpu do |state|
|
292
|
+
# access the nested state for whatever you need...
|
293
|
+
host = Host.new(state.host.name)
|
294
|
+
host.cpus = CPU.new(state.host.resources.cpu_units)
|
295
|
+
|
296
|
+
host
|
297
|
+
end
|
298
|
+
end
|
299
|
+
```
|
300
|
+
|
301
|
+
Calling `as_json` on a state will recurse through the nesting to produce a nested hash.
|
302
|
+
|
303
|
+
## Type Definitions
|
304
|
+
|
305
|
+
Smash the State supports re-usable type definitions that can help to DRY up your operation states. In the above nested schema example, we can DRY up the host schema by turning it into a definition:
|
306
|
+
|
307
|
+
``` ruby
|
308
|
+
class HostDefinition < SmashTheState::Operation::Definition
|
309
|
+
definition "Host"
|
310
|
+
|
311
|
+
schema do
|
312
|
+
attribute :name, :string
|
313
|
+
|
314
|
+
schema :resources do
|
315
|
+
attribute :ram_units, :integer
|
316
|
+
attribute :cpu_units, :integer
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
```
|
321
|
+
|
322
|
+
Which can then be re-used in other schemas:
|
323
|
+
|
324
|
+
``` ruby
|
325
|
+
class Database::Create < Compose::Operation
|
326
|
+
schema do
|
327
|
+
attribute :type, :string
|
328
|
+
schema :host, ref: HostDefinition
|
329
|
+
end
|
330
|
+
end
|
331
|
+
```
|
332
|
+
|
333
|
+
## Dry Runs
|
334
|
+
|
335
|
+
Operations support "dry run" execution. That is to say, dry runs should not produce side-effects but produce output that is similar to the output for a regular run. Because dry runs should not produce side-effects, steps that persist changes cannot be executed safely for dry runs.
|
336
|
+
|
337
|
+
To get around this, a specific sequence can be defined for use when running dry. The dry run sequence can refer to steps in the normal sequence by name. Alternatively, if the step produces side-effects, an alternate version of the step can be provided that superficially behaves similarly. Steps also may be skipped entirely simply by omission.
|
338
|
+
|
339
|
+
|
340
|
+
``` ruby
|
341
|
+
class CreateUserOperation < SmashTheState::Operation
|
342
|
+
schema do
|
343
|
+
attribute :email, :string
|
344
|
+
attribute :name, :string
|
345
|
+
attribute :age, :integer
|
346
|
+
attribute :id, :integer
|
347
|
+
end
|
348
|
+
|
349
|
+
step :downcase_email do |state|
|
350
|
+
state.name ||= "Unnamed"
|
351
|
+
state
|
352
|
+
end
|
353
|
+
|
354
|
+
validate do
|
355
|
+
validates_presence_of :email
|
356
|
+
end
|
357
|
+
|
358
|
+
# because this step creates a resource, it produces side-effects and is not
|
359
|
+
# safe for a dry run
|
360
|
+
step :create do |state|
|
361
|
+
result = SomeServer.post("/users")
|
362
|
+
state.id = result.id
|
363
|
+
state
|
364
|
+
end
|
365
|
+
|
366
|
+
step :add_phd do |state|
|
367
|
+
state.name = state.name + " Ph.D"
|
368
|
+
state
|
369
|
+
end
|
370
|
+
|
371
|
+
dry_run_sequence do
|
372
|
+
step :downcase_email
|
373
|
+
|
374
|
+
# this alternative implementation will instead be executed when run dry,
|
375
|
+
# allowing the rest of the operation to function as if the :create step had run
|
376
|
+
step :create do |state|
|
377
|
+
state.id = (Random.rand * 1000).ceil
|
378
|
+
state
|
379
|
+
end
|
380
|
+
|
381
|
+
step :add_phd
|
382
|
+
end
|
383
|
+
end
|
384
|
+
```
|
385
|
+
|
386
|
+
``` ruby
|
387
|
+
> result = CreateUserOperation.dry_run(email: "jack@sparrow.com", age: 31)
|
388
|
+
> result.errors.empty?
|
389
|
+
=> true
|
390
|
+
> result.name
|
391
|
+
=> "Unnamed Ph.D"
|
392
|
+
> result.id
|
393
|
+
=> 145
|
394
|
+
```
|
395
|
+
|
396
|
+
``` ruby
|
397
|
+
> result = CreateUserOperation.dry_run(name: "Sam", age: 31)
|
398
|
+
> result.errors.empty?
|
399
|
+
=> false
|
400
|
+
> result.errors["email"]
|
401
|
+
=> ["can't be blank"]
|
402
|
+
> result.id
|
403
|
+
=> nil
|
404
|
+
```
|
405
|
+
|
406
|
+
## Specs
|
407
|
+
|
408
|
+
Some helpful RSpec helpers are provided.
|
409
|
+
|
410
|
+
``` ruby
|
411
|
+
# spec_helper.rb
|
412
|
+
require 'smash_the_state/matchers'
|
413
|
+
```
|
414
|
+
|
415
|
+
``` ruby
|
416
|
+
# continues_from
|
417
|
+
expect(ContinuingOperation).to continue_from PreludeOperation
|
418
|
+
|
419
|
+
# represent
|
420
|
+
expect(RepresentingOperation).to represent_with SomeRepresenter
|
421
|
+
expect(RepresentingOperation).to represent_collection_with SomeRepresenter
|
422
|
+
```
|
data/Rakefile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'bundler/gem_tasks'
|
8
|
+
|
9
|
+
namespace :bump do
|
10
|
+
task :patch do
|
11
|
+
system "bump patch --tag"
|
12
|
+
end
|
13
|
+
|
14
|
+
task :minor do
|
15
|
+
system "bump minor --tag"
|
16
|
+
end
|
17
|
+
|
18
|
+
task :major do
|
19
|
+
system "bump major --tag"
|
20
|
+
end
|
21
|
+
end
|