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 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