nxt_pipeline 0.4.2 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -2,7 +2,17 @@
2
2
 
3
3
  # NxtPipeline
4
4
 
5
- nxt_pipeline provides a DSL to define pipeline classes which take an object and pass it through multiple steps which can read or modify the object.
5
+ NxtPipeline is an orchestration framework for your service objects or function objects, how I like to call them.
6
+ Service objects are a very wide spread way of organizing code in the Ruby and Rails communities. Since it's little classes
7
+ doing one thing you can think of them as function objects and thus they often share a common interface in a project.
8
+ There are also many frameworks out there that normalize the usage of service objects and provide a specific way
9
+ of writing service objects and often also allow to orchestrate (reduce) these service objects.
10
+ Compare [light-service](https://github.com/adomokos/light-service) for instance.
11
+
12
+ The idea of NxtPipeline was to build a flexible orchestration framework for service objects without them having to conform
13
+ to a specific interface. Instead NxtPipeline expects you to specify how to execute different kinds of service objects
14
+ through so called constructors and thereby does not dictate you how to write your service objects. Nevertheless this still
15
+ mostly makes sense if your service objects share common interfaces to keep the necessary configuration to a minimum.
6
16
 
7
17
  ## Installation
8
18
 
@@ -22,186 +32,432 @@ Or install it yourself as:
22
32
 
23
33
  ## Usage
24
34
 
25
- ### Constructors
35
+ ### Example
26
36
 
27
- First you probably want to configure a pipeline so that it can execute your steps.
28
- Therefore you want to define constructors for your steps. Constructors take a name
29
- as the first argument and step options as the second. All step options are being exposed
30
- by the step yielded to the constructor.
37
+ Let's look at an example. Here validator service objects are orchestrated with NxtPipeline to build a validation
38
+ pipeline. We inject the accumulator `{ value: 'aki', errors: [] }` that is then passed through all validation steps.
39
+ If an validator returns an error it's added to the array of errors of the accumulator to collect all errors of all steps.
31
40
 
32
41
  ```ruby
33
- pipeline = NxtPipeline::Pipeline.new do |p|
34
- # Add a named constructor that will be used to execute your steps later
35
- # All options that you pass in your step will be available through accessors in your constructor
36
- # You can call :to_s on a step to set it by default. You can later overwrite at execution for each step if needed.
37
- p.constructor(:service, default: true) do |step, arg:|
38
- step.to_s = step.service_class.to_s
39
- result = step.service_class.new(options: arg).call
40
- result && { arg: result }
42
+ class Validator
43
+ attr_accessor :error
44
+ end
45
+
46
+ class TypeChecker < Validator
47
+ def initialize(value, type:)
48
+ @value = value
49
+ @type = type
50
+ end
51
+
52
+ attr_reader :value, :type
53
+
54
+ def call
55
+ return if value.is_a?(type)
56
+ self.error = "Value does not match type #{type}"
57
+ end
58
+ end
59
+
60
+ class MinSize < Validator
61
+ def initialize(value, size:)
62
+ @value = value
63
+ @size = size
64
+ end
65
+
66
+ attr_reader :value, :size
67
+
68
+ def call
69
+ return if value.size >= size
70
+ self.error = "Value size must be greater than #{size-1}"
71
+ end
72
+ end
73
+
74
+ class MaxSize < Validator
75
+ def initialize(value, size:)
76
+ @value = value
77
+ @size = size
41
78
  end
42
-
43
- p.constructor(:job) do |step, arg:|
44
- step.job_class.perform_later(*arg) && { arg: arg }
79
+
80
+ attr_reader :value, :size
81
+
82
+ def call
83
+ return if value.size <= size
84
+ self.error = "Value size must be less than #{size+1}"
45
85
  end
46
86
  end
47
87
 
48
- # Once a pipeline was created you can still configure it
49
- pipeline.constructor(:call) do |step, arg:|
50
- result = step.caller.new(arg).call
51
- result && { arg: result }
88
+ class Uniqueness < Validator
89
+ def initialize(value, scope:)
90
+ @value = value
91
+ @scope = scope
92
+ end
93
+
94
+ attr_reader :value, :scope
95
+
96
+ def call
97
+ return if scope.count { |item| item == value }
98
+ self.error = "Value is not unique in: #{scope}"
99
+ end
52
100
  end
53
101
 
54
- # same with block syntax
55
- # You can use this to split up execution from configuration
56
- pipeline.configure do |p|
57
- p.constructor(:call) do |step, arg:|
58
- result = step.caller.new(arg).call
59
- result && { arg: result }
60
- end
102
+ result = NxtPipeline.call({ value: 'aki', errors: [] }) do |p|
103
+ p.constructor(:validator, default: true) do |acc, step|
104
+ validator = step.argument.new(acc.fetch(:value), **step.options)
105
+ validator.call
106
+ acc[:errors] << validator.error if validator.error.present?
107
+
108
+ acc
109
+ end
110
+
111
+ p.step TypeChecker, options: { type: String }
112
+ p.step MinSize, options: { size: 4 }
113
+ p.step MaxSize, options: { size: 10 }
114
+ p.step Uniqueness, options: { scope: ['andy', 'aki', 'lütfi', 'rapha'] }
61
115
  end
116
+
117
+ result # => { value: 'aki', errors: ['Value size must be greater than 3'] }
62
118
  ```
63
119
 
64
- ### Defining steps
120
+ ### Constructors
121
+
122
+ In order to reduce over your service objects you have to define constructors so that the pipeline knows how to execute
123
+ a specific step. You can define constructors globally and specific to a pipeline.
65
124
 
66
- Once your pipeline knows how to execute your steps you can add those.
125
+ Make a constructor available for all pipelines of your project by defining it globally with:
67
126
 
68
127
  ```ruby
69
- pipeline.step :service, service_class: MyServiceClass, to_s: 'First step'
70
- pipeline.step service_class: MyOtherServiceClass, to_s: 'Second step'
71
- # ^ Since service is the default step you don't have to specify it the step type each time
72
- pipeline.step :job, job_class: MyJobClass # to_s is optional
73
- pipeline.step :job, job_class: MyOtherJobClass
128
+ NxtPipeline.constructor(:service) do |acc, step|
129
+ validator = step.argument.new(acc.fetch(:value), **step.options)
130
+ validator.call
131
+ acc[:errors] << validator.error if validator.error.present?
74
132
 
75
- pipeline.step :step_name_for_better_log do |_, arg:|
76
- # ...
133
+ acc
77
134
  end
135
+ ```
136
+
137
+ Or define a constructor only locally for a specific pipeline.
78
138
 
79
- pipeline.step to_s: 'This is the same as above' do |step, arg:|
80
- # ... step.to_s => 'This is the same as above'
139
+ ```ruby
140
+ NxtPipeline.new({ value: 'aki', errors: [] }) do |p|
141
+ p.constructor(:validator, default: true) do |acc, step|
142
+ validator = step.argument.new(acc.fetch(:value), **step.options)
143
+ validator.call
144
+ acc[:errors] << validator.error if validator.error.present?
145
+
146
+ acc
147
+ end
148
+
149
+ p.step TypeChecker, options: { type: String }
150
+ # ...
81
151
  end
82
152
  ```
83
153
 
84
- You can also define inline steps, meaning the block will be executed. When you do not provide a :to_s option, type
85
- will be used as :to_s option per default. When no type was given for an inline block the type of the inline block
86
- will be set to :inline.
87
-
88
- ### Execution
154
+ Constructor Hierarchy
89
155
 
90
- You can then execute the steps with:
156
+ In order to execute a specific step the pipeline firstly checks whether a constructor was specified for a step:
157
+ `pipeline.step MyServiceClass, constructor: :service`. If this is not the case it checks whether there is a resolver
158
+ registered that applies. If that's not the case the pipeline checks if there is a constructor registered for the
159
+ argument that was passed in. This means if you register constructors directly for the arguments you pass in you don't
160
+ have to specify this constructor option. Therefore the following would work without the need to provide a constructor
161
+ for the steps.
91
162
 
92
163
  ```ruby
93
- pipeline.execute(arg: 'initial argument')
164
+ NxtPipeline.new({}) do |p|
165
+ p.constructor(:service) do |acc, step|
166
+ step.service_class.new(acc).call
167
+ end
94
168
 
95
- # Or run the steps directly using block syntax
169
+ p.step :service, service_class: MyServiceClass
170
+ p.step :service, service_class: MyOtherServiceClass
171
+ # ...
172
+ end
173
+ ```
174
+
175
+ Lastly if no constructor could be resolved directly from the step argument, the pipelines falls back to the locally
176
+ and then to the globally defined default constructors.
96
177
 
97
- pipeline.execute(arg: 'initial argument') do |p|
98
- p.step :service, service_class: MyServiceClass, to_s: 'First step'
99
- p.step :service, service_class: MyOtherServiceClass, to_s: 'Second step'
100
- p.step :job, job_class: MyJobClass # to_s is optional
101
- p.step :job, job_class: MyOtherJobClass
178
+ ### Defining steps
179
+
180
+ Once your pipeline knows how to execute your steps you can add those. The `pipeline.step` method expects at least one
181
+ argument which you can access in the constructor through `step.argument`. You can also pass in additional options
182
+ that you can access through readers of a step. The `constructor:` option defines which constructor to use for a step
183
+ where as you can name a step with the `to_s:` option.
184
+
185
+ ```ruby
186
+ # explicitly define which constructor to use
187
+ pipeline.step MyServiceClass, constructor: :service
188
+ # use a block as inline constructor
189
+ pipeline.step SpecialService, constructor: ->(step, arg:) { step.argument.call(arg: arg) }
190
+ # Rely on the default constructor
191
+ pipeline.step MyOtherServiceClass
192
+ # Define a step name
193
+ pipeline.step MyOtherServiceClass, to_s: 'First Step'
194
+ # Or simply execute a (named) block - NO NEED TO DEFINE A CONSTRUCTOR HERE
195
+ pipeline.step :step_name_for_better_log do |acc, step|
196
+ # ...
102
197
  end
198
+ ```
103
199
 
200
+ Defining multiple steps at once. This is especially useful to dynamically configure a pipeline for execution and
201
+ can potentially even come from a yaml configuration or from the database.
202
+
203
+ ```ruby
204
+ pipeline.steps([
205
+ [MyServiceClass, constructor: :service],
206
+ [MyOtherServiceClass, constructor: :service],
207
+ [MyJobClass, constructor: :job]
208
+ ])
209
+
210
+ # You can also overwrite the steps of a pipeline through explicitly setting them. This will remove any previously
211
+ # defined steps.
212
+ pipeline.steps = [
213
+ [MyServiceClass, constructor: :service],
214
+ [MyOtherServiceClass, constructor: :service]
215
+ ]
104
216
  ```
105
217
 
106
- You can also directly execute a pipeline with:
218
+ ### Execution
219
+
220
+ Once a pipeline contains steps you can call it with `call(accumulator)` whereas it expects you to inject the accumulator
221
+ as argument that is then passed through all steps.
107
222
 
108
223
  ```ruby
109
- NxtPipeline::Pipeline.execute(arg: 'initial argument') do |p|
110
- p.step do |_, arg:|
111
- { arg: arg.upcase }
112
- end
224
+ pipeline.call(arg: 'initial argument')
225
+
226
+ # Or directly pass the steps you want to execute:
227
+ pipeline.call(arg: 'initial argument') do |p|
228
+ p.step MyServiceClass, to_s: 'First step'
229
+ p.step MyOtherServiceClass, to_s: 'Second step'
230
+ p.step MyJobClass, constructor: :job
231
+ p.step MyOtherJobClass, constructor: :job
113
232
  end
114
- ```
233
+ ```
115
234
 
116
- You can query the steps of your pipeline simply by calling `pipeline.steps`. A NxtPipeline::Step will provide you with
117
- an interface to it's type, options, status (:success, :skipped, :failed), result, error and the index in the pipeline.
235
+ You can also create a new instance of a pipeline and directly run it with `call`:
118
236
 
237
+ ```ruby
238
+ NxtPipeline.call(arg: 'initial argument') do |p|
239
+ p.steps # ...
240
+ end
119
241
  ```
120
- pipeline.steps.first
121
- # will give you something like this:
122
242
 
123
- #<NxtPipeline::Step:0x00007f83eb399448
124
- @constructor=
125
- #<Proc:0x00007f83eb399498@/Users/andy/workspace/nxt_pipeline/spec/pipeline_spec.rb:467>,
126
- @error=nil,
127
- @index=0,
128
- @opts={:to_s=>:transformer, :method=>:upcase},
129
- @result=nil,
130
- @status=nil,
131
- @type=:transformer>
132
- ```
243
+ You can query the steps of your pipeline simply by calling `pipeline.steps`. A NxtPipeline::Step will provide you with
244
+ an interface for options, status, execution_finished_at execution_started_at,
245
+ execution_duration, result, error and the index in the pipeline.
246
+
247
+ ```
248
+ pipeline.steps.first
249
+ # will give you a step object
250
+ #<NxtPipeline::Step:0x00007f83eb399448...>
251
+ ```
133
252
 
134
253
  ### Guard clauses
135
254
 
136
255
  You can also define guard clauses that take a proc to prevent the execution of a step.
137
- When the guard takes an argument the step argument is yielded.
256
+ A guard can accept the change set and the step as arguments.
138
257
 
139
258
  ```ruby
140
- pipeline.execute(arg: 'initial argument') do |p|
141
- p.step :service, service_class: MyServiceClass, if: -> (arg:) { arg == 'initial argument' }
142
- p.step :service, service_class: MyOtherServiceClass, unless: -> { false }
143
- end
144
-
259
+ pipeline.call('initial argument') do |p|
260
+ p.step MyServiceClass, if: -> (acc, step) { acc == 'initial argument' }
261
+ p.step MyOtherServiceClass, unless: -> { false }
262
+ end
263
+
145
264
  ```
146
265
 
147
266
  ### Error callbacks
148
267
 
149
- Apart from defining constructors and steps you can also define error callbacks.
268
+ Apart from defining constructors and steps you can also define error callbacks. Error callbacks can accept up to
269
+ three arguments: `error, acc, step`.
150
270
 
151
271
  ```ruby
152
- NxtPipeline::Pipeline.new do |p|
153
- p.step do |_, arg:|
154
- { arg: arg.upcase }
155
- end
156
-
157
- p.on_error MyCustomError do |step, opts, error|
272
+ NxtPipeline.new do |p|
273
+ p.step # ...
274
+
275
+ p.on_error MyCustomError do |error|
158
276
  # First matching error callback will be executed!
159
277
  end
160
-
161
- p.on_errors ArgumentError, KeyError do |step, opts, error|
278
+
279
+ p.on_errors ArgumentError, KeyError do |error, acc|
162
280
  # First matching error callback will be executed!
163
281
  end
164
-
165
- p.on_errors YetAnotherError, halt_on_error: false do |step, opts, error|
282
+
283
+ p.on_errors YetAnotherError, halt_on_error: false do |error, acc, step|
166
284
  # After executing the callback the pipeline will not halt but continue to
167
285
  # execute the next steps.
168
286
  end
169
-
170
- p.on_errors do |step, opts, error|
287
+
288
+ p.on_errors do |error, acc, step|
171
289
  # This will match all errors inheriting from StandardError
172
290
  end
173
291
  end
174
- ```
292
+ ```
293
+
294
+ ### Before, around and after callbacks
295
+
296
+ You can also define callbacks :before, :around and :after each step and or the `#execute` method. You can also register
297
+ multiple callbacks, but probably you want to keep them to a minimum to not end up in hell. Also note that before and
298
+ after callbacks will run even if a step was skipped through a guard clause.
175
299
 
176
- ### Before and After callbacks
300
+ #### Step callbacks
301
+
302
+ ```ruby
303
+ NxtPipeline.new do |p|
304
+ p.before_step do |_, change_set|
305
+ change_set[:acc] << 'before step 1'
306
+ change_set
307
+ end
308
+
309
+ p.around_step do |_, change_set, execution|
310
+ change_set[:acc] << 'around step 1'
311
+ execution.call # you have to specify where in your callback you want to call the inner block
312
+ change_set[:acc] << 'around step 1'
313
+ change_set
314
+ end
315
+
316
+ p.after_step do |_, change_set|
317
+ change_set[:acc] << 'after step 1'
318
+ change_set
319
+ end
320
+ end
321
+ ```
177
322
 
178
- You can also define callbacks that run before and after the `#execute` action. Both callback blocks get the pipeline instance (to access stuff like the `log`) and the argument of the pipeline yielded.
323
+ #### Execution callbacks
179
324
 
180
325
  ```ruby
181
- NxtPipeline::Pipeline.new do |p|
182
- p.before_execute do |pipeline, arg:|
183
- # Will be called from within #execute before entering the first step
184
- # After any configure block though!
326
+ NxtPipeline.new do |p|
327
+ p.before_execution do |_, change_set|
328
+ change_set[:acc] << 'before execution 1'
329
+ change_set
330
+ end
331
+
332
+ p.around_execution do |_, change_set, execution|
333
+ change_set[:acc] << 'around execution 1'
334
+ execution.call # you have to specify where in your callback you want to call the inner block
335
+ change_set[:acc] << 'around execution 1'
336
+ change_set
185
337
  end
186
-
187
- p.after_execute do |pipeline, arg:|
188
- # Will be called from within #execute after executing last step
338
+
339
+ p.after_execution do |_, change_set|
340
+ change_set[:acc] << 'after execution 1'
341
+ change_set
189
342
  end
190
343
  end
191
344
  ```
192
345
 
193
- Note that the `after_execute` callback will not be called, when an error is raised in one of the steps. See the previous section (_Error callbacks_) for how to define callbacks that run in case of errors.
346
+ Note that the `after_execute` callback will not be called in case a step raises an error.
347
+ See the previous section (_Error callbacks_) for how to define callbacks that run in case of errors.
348
+
349
+ ### Constructor resolvers
194
350
 
195
- ### Step resolvers
351
+ You can also define constructor resolvers for a pipeline to dynamically define which previously registered constructor
352
+ to use for a step based on the argument and options passed to the step.
196
353
 
197
- NxtPipeline is using so called step_resolvers to find the constructor for a given step by the arguments passed in.
198
- You can also use this if you are not fine with resolving the constructor from the step argument. Check out the
199
- `nxt_pipeline/spec/step_resolver_spec.rb` for examples how you can implement your own step_resolvers.
354
+ ```ruby
355
+ class Transform
356
+ def initialize(word, operation)
357
+ @word = word
358
+ @operation = operation
359
+ end
200
360
 
361
+ attr_reader :word, :operation
362
+
363
+ def call
364
+ word.send(operation)
365
+ end
366
+ end
367
+
368
+ NxtPipeline.new do |pipeline|
369
+ # dynamically resolve to use a proc as constructor
370
+ pipeline.constructor_resolver do |argument, **opts|
371
+ argument.is_a?(Class) &&
372
+ ->(step, arg:) {
373
+ result = step.argument.new(arg, opts.fetch(:operation)).call
374
+ # OR result = step.argument.new(arg, step.operation).call
375
+ { arg: result }
376
+ }
377
+ end
378
+
379
+ # dynamically resolve to a defined constructor
380
+ pipeline.constructor_resolver do |argument|
381
+ argument.is_a?(String) && :dynamic
382
+ end
383
+
384
+ pipeline.constructor(:dynamic) do |step, arg:|
385
+ if step.argument == 'multiply'
386
+ { arg: arg * step.multiplier }
387
+ elsif step.argument == 'symbolize'
388
+ { arg: arg.to_sym }
389
+ else
390
+ raise ArgumentError, "Don't know how to deal with argument: #{step.argument}"
391
+ end
392
+ end
393
+
394
+ pipeline.step Transform, operation: 'upcase'
395
+ pipeline.step 'multiply', multiplier: 2
396
+ pipeline.step 'symbolize'
397
+ pipeline.step :extract_value do |arg|
398
+ arg
399
+ end
400
+ end
401
+ ```
402
+
403
+ ### Configurations
404
+
405
+ You probably do not have that many different kinds of steps that you execute within your pipelines. Otherwise the whole
406
+ concept does not make much sense. To make constructing a pipeline simpler you can therefore define configurations on
407
+ a global level simply by providing a name for a configuration along with a configuration block.
408
+ Then you then create a preconfigure pipeline by passing in the name of the configuration when creating a new pipeline.
409
+
410
+ ```ruby
411
+ # Define configurations in your initializer or somewhere upfront
412
+ NxtPipeline.configuration(:test_processor) do |pipeline|
413
+ pipeline.constructor(:processor) do |arg, step|
414
+ { arg: step.argument.call(arg: arg) }
415
+ end
416
+ end
417
+
418
+ NxtPipeline.configure(:validator) do |pipeline|
419
+ pipeline.constructor(:validator) do |arg, step|
420
+ # ..
421
+ end
422
+ end
423
+
424
+ # ...
425
+
426
+ # Later create a pipeline with a previously defined configuration
427
+ NxtPipeline.new(configuration: :test_processor) do |p|
428
+ p.step ->(arg) { arg + 'first ' }, constructor: :processor
429
+ p.step ->(arg) { arg + 'second ' }, constructor: :processor
430
+ p.step ->(arg) { arg + 'third' }, constructor: :processor
431
+ end
432
+ ```
433
+
434
+ ### Step status and meta_data
435
+ When executing your steps you can also log the status of a step by setting it in your constructors or callbacks in
436
+ which you have access to the steps.
437
+
438
+ ```ruby
439
+ pipeline = NxtPipeline.new do |pipeline|
440
+ pipeline.constructor(:step, default: true) do |acc, step|
441
+ result = step.proc.call(acc)
442
+ step.status = result.present? # Set the status here
443
+ step.meta_data = 'additional info' # or some meta data
444
+ acc
445
+ end
446
+
447
+ pipeline.step :first_step do |acc, step|
448
+ step.status = 'it worked'
449
+ step.meta_data = { extra: 'info' }
450
+ acc
451
+ end
452
+
453
+ pipeline.step :second, proc: ->(acc) { acc }
454
+ end
455
+
456
+ pipeline.logger.log # => { "first_step" => 'it worked', "second" => true }
457
+ pipeline.steps.map(&:meta_data) # => [{:extra=>"info"}, "additional info"]
458
+ ```
201
459
 
202
460
  ## Topics
203
- - Step orchestration (chainable steps)
204
- - Constructors should take arg as first and step as second arg
205
461
 
206
462
  ## Development
207
463
 
@@ -0,0 +1,61 @@
1
+ module NxtPipeline
2
+ class Callbacks
3
+ def initialize(pipeline:)
4
+ @registry = build_registry
5
+ @pipeline = pipeline
6
+ end
7
+
8
+ def register(path, callback)
9
+ registry.resolve!(*path) << callback
10
+ end
11
+
12
+ def run(kind_of_callback, type, change_set)
13
+ callbacks = registry.resolve!(kind_of_callback, type)
14
+ return unless callbacks.any?
15
+
16
+ callbacks.inject(change_set) do |changes, callback|
17
+ run_callback(callback, changes)
18
+ end
19
+ end
20
+
21
+ def around(type, change_set, &execution)
22
+ around_callbacks = registry.resolve!(:around, type)
23
+ return execution.call unless around_callbacks.any?
24
+
25
+ callback_chain = around_callbacks.reverse.inject(execution) do |previous, callback|
26
+ -> { callback.call(change_set, previous, pipeline) }
27
+ end
28
+
29
+ callback_chain.call
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :registry, :pipeline
35
+
36
+ def run_callback(callback, change_set)
37
+ args = [change_set, pipeline]
38
+ args = args.take(callback.arity) unless callback.arity.negative?
39
+ callback.call(*args)
40
+ end
41
+
42
+ def build_registry
43
+ NxtRegistry::Registry.new(:callbacks) do
44
+ register(:before) do
45
+ register(:step, [])
46
+ register(:execution, [])
47
+ end
48
+
49
+ register(:around) do
50
+ register(:step, [])
51
+ register(:execution, [])
52
+ end
53
+
54
+ register(:after) do
55
+ register(:step, [])
56
+ register(:execution, [])
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -20,8 +20,11 @@ module NxtPipeline
20
20
  (error.class.ancestors & errors).any?
21
21
  end
22
22
 
23
- def call(step, arg, error)
24
- callback.call(step, arg, error)
23
+ def call(error, acc, step)
24
+ args = [error, acc, step]
25
+ args = args.take(callback.arity) unless callback.arity.negative?
26
+
27
+ callback.call(*args)
25
28
  end
26
29
  end
27
30
  end