nxt_pipeline 0.4.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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