business_pipeline 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fffa062373cea0a470d7c9f8c35843f95448c3c9c31fdbdf5be11c57f8c937e0
4
+ data.tar.gz: 720225822fa786111bdd11e3abf54fdcc1388a1baca3dd8c8c94e45db11230a4
5
+ SHA512:
6
+ metadata.gz: f1d1e38de02437aff15fedf896e8f3020b74ae95f9ddbe6d2abec26efc35158ace21841348dc3f504a799e22d5ca4158a64d14b755d19226e4b4c39b4a2631ac
7
+ data.tar.gz: c41e391563e1d73dbb7a4fc54e5f3b143d1c388caee4c51f3dae09b9e6500bdae4633914e8a5cc25944834f3ae5c978ce6838075289a3bdba2e0f97fb5eb64ff
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /spec/examples.txt
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,87 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.5.3
3
+
4
+ Layout/AlignParameters:
5
+ EnforcedStyle: with_fixed_indentation
6
+
7
+ Layout/EmptyLineAfterGuardClause:
8
+ Enabled: false
9
+
10
+ # Layout/FirstParameterIndentation:
11
+ # Exclude:
12
+ # - 'spec/**/*_spec.rb'
13
+
14
+ Layout/IndentArray:
15
+ EnforcedStyle: consistent
16
+
17
+ Layout/IndentHash:
18
+ EnforcedStyle: consistent
19
+
20
+ Layout/MultilineMethodCallBraceLayout:
21
+ EnforcedStyle: same_line
22
+
23
+ Layout/MultilineMethodDefinitionBraceLayout:
24
+ EnforcedStyle: same_line
25
+
26
+ Layout/MultilineOperationIndentation:
27
+ EnforcedStyle: indented
28
+ IndentationWidth: 2
29
+
30
+ Metrics/BlockLength:
31
+ Exclude:
32
+ - 'spec/**/*.rb'
33
+
34
+ Metrics/LineLength:
35
+ Exclude:
36
+ - 'spec/**/*_spec.rb'
37
+ Max: 90
38
+
39
+ Naming/PredicateName:
40
+ NamePrefixBlacklist:
41
+ - is_
42
+
43
+ Style/AccessModifierDeclarations:
44
+ Enabled: false
45
+
46
+ Style/Alias:
47
+ EnforcedStyle: prefer_alias_method
48
+
49
+ Style/DoubleNegation:
50
+ Enabled: false
51
+
52
+ Style/FrozenStringLiteralComment:
53
+ Exclude:
54
+ - 'bin/**'
55
+
56
+ # Style/MultilineBlockChain:
57
+ # Enabled: false
58
+
59
+ Style/PercentLiteralDelimiters:
60
+ PreferredDelimiters:
61
+ '%': '{}'
62
+ '%i': '[]'
63
+ '%q': '{}'
64
+ '%Q': '{}'
65
+ '%r': '{}'
66
+ '%s': '{}'
67
+ '%w': '[]'
68
+ '%W': '[]'
69
+ '%x': '{}'
70
+
71
+ Style/RegexpLiteral:
72
+ EnforcedStyle: mixed
73
+
74
+ Style/SignalException:
75
+ EnforcedStyle: semantic
76
+
77
+ Style/SingleLineBlockParams:
78
+ Enabled: false
79
+
80
+ Style/StringLiterals:
81
+ EnforcedStyle: single_quotes
82
+
83
+ Style/StringLiteralsInInterpolation:
84
+ EnforcedStyle: single_quotes
85
+
86
+ Style/YodaCondition:
87
+ EnforcedStyle: equality_operators_only
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at scourtois_github@cubyx.fr. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in business_pipeline.gemspec
8
+ gemspec
@@ -0,0 +1,98 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ business_pipeline (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.0)
10
+ coderay (1.1.2)
11
+ diff-lcs (1.3)
12
+ docile (1.3.1)
13
+ ffi (1.10.0)
14
+ formatador (0.2.5)
15
+ guard (2.15.0)
16
+ formatador (>= 0.2.4)
17
+ listen (>= 2.7, < 4.0)
18
+ lumberjack (>= 1.0.12, < 2.0)
19
+ nenv (~> 0.1)
20
+ notiffany (~> 0.0)
21
+ pry (>= 0.9.12)
22
+ shellany (~> 0.0)
23
+ thor (>= 0.18.1)
24
+ guard-compat (1.2.1)
25
+ guard-rspec (4.7.3)
26
+ guard (~> 2.1)
27
+ guard-compat (~> 1.1)
28
+ rspec (>= 2.99.0, < 4.0)
29
+ jaro_winkler (1.5.1)
30
+ json (2.1.0)
31
+ listen (3.1.5)
32
+ rb-fsevent (~> 0.9, >= 0.9.4)
33
+ rb-inotify (~> 0.9, >= 0.9.7)
34
+ ruby_dep (~> 1.2)
35
+ lumberjack (1.0.13)
36
+ method_source (0.9.2)
37
+ nenv (0.3.0)
38
+ notiffany (0.1.1)
39
+ nenv (~> 0.1)
40
+ shellany (~> 0.0)
41
+ parallel (1.12.1)
42
+ parser (2.5.1.2)
43
+ ast (~> 2.4.0)
44
+ powerpack (0.1.2)
45
+ pry (0.12.2)
46
+ coderay (~> 1.1.0)
47
+ method_source (~> 0.9.0)
48
+ rainbow (3.0.0)
49
+ rake (10.5.0)
50
+ rb-fsevent (0.10.3)
51
+ rb-inotify (0.10.0)
52
+ ffi (~> 1.0)
53
+ rspec (3.8.0)
54
+ rspec-core (~> 3.8.0)
55
+ rspec-expectations (~> 3.8.0)
56
+ rspec-mocks (~> 3.8.0)
57
+ rspec-core (3.8.0)
58
+ rspec-support (~> 3.8.0)
59
+ rspec-expectations (3.8.2)
60
+ diff-lcs (>= 1.2.0, < 2.0)
61
+ rspec-support (~> 3.8.0)
62
+ rspec-mocks (3.8.0)
63
+ diff-lcs (>= 1.2.0, < 2.0)
64
+ rspec-support (~> 3.8.0)
65
+ rspec-support (3.8.0)
66
+ rubocop (0.60.0)
67
+ jaro_winkler (~> 1.5.1)
68
+ parallel (~> 1.10)
69
+ parser (>= 2.5, != 2.5.1.1)
70
+ powerpack (~> 0.1)
71
+ rainbow (>= 2.2.2, < 4.0)
72
+ ruby-progressbar (~> 1.7)
73
+ unicode-display_width (~> 1.4.0)
74
+ ruby-progressbar (1.10.0)
75
+ ruby_dep (1.5.0)
76
+ shellany (0.0.1)
77
+ simplecov (0.16.1)
78
+ docile (~> 1.1)
79
+ json (>= 1.8, < 3)
80
+ simplecov-html (~> 0.10.0)
81
+ simplecov-html (0.10.2)
82
+ thor (0.20.3)
83
+ unicode-display_width (1.4.0)
84
+
85
+ PLATFORMS
86
+ ruby
87
+
88
+ DEPENDENCIES
89
+ bundler (~> 1.16)
90
+ business_pipeline!
91
+ guard-rspec (~> 4.7)
92
+ rake (~> 10.0)
93
+ rspec (~> 3.6)
94
+ rubocop (~> 0.60.0)
95
+ simplecov (~> 0.16.1)
96
+
97
+ BUNDLED WITH
98
+ 1.17.2
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ guard :rspec, cmd: 'bundle exec rspec', all_after_pass: true do
4
+ require 'guard/rspec/dsl'
5
+ dsl = Guard::RSpec::Dsl.new(self)
6
+
7
+ # RSpec files
8
+ rspec = dsl.rspec
9
+ watch(rspec.spec_helper) { rspec.spec_dir }
10
+ watch(rspec.spec_support) { rspec.spec_dir }
11
+ watch(rspec.spec_files)
12
+
13
+ # Ruby files
14
+ ruby = dsl.ruby
15
+ dsl.watch_spec_files_for(ruby.lib_files)
16
+ end
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Simon Courtois
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,445 @@
1
+ # BusinessPipeline
2
+
3
+ BusinessPipeline (BP) aim is to help organize your app's logic in a generic way. You define business bricks that you can then plug together to build more eveolved pipelines.
4
+
5
+ While it was developed with Rails in mind, BP has no dependency upon it and can be used to organize pretty much any Ruby code.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'business_pipeline'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install business_pipeline
22
+
23
+ ## A touch of generic
24
+
25
+ Let's say you build an API using Rails. Would you agree that most of the time, CRUD actions look pretty much alike?
26
+
27
+ If your answer is "yes" or "yes but…" BusinessPipeline might be a good fit for you! What if you could write a single process to list resources in a Rails application? Wouldn't it be cool to have a generic code that can adapt to most if not all of your use-cases?
28
+
29
+ BusinessPipeline has been extracted from applications that did just that. And if you were wondering, no those apps weren't 15-minute blogs 😁.
30
+
31
+ A typical _index_ process would look like:
32
+
33
+ ```ruby
34
+ module Processes
35
+ class Index < ApplicationProcess
36
+ step Steps::FetchAll
37
+ step Steps::Sort
38
+ step Steps::Paginate
39
+ end
40
+ end
41
+ ```
42
+
43
+ This may look like a trivial example but the power of this simplicity resides in how generic the Steps of your Process are.
44
+
45
+ We'll start be looking at the basics of working with BusinessPipeline and then move on to how you could leverage its power in a Rails application.
46
+
47
+ ## Basic usage
48
+
49
+ ### Defining a Process
50
+
51
+ Let's start from the very beginning and define a very focused business process:
52
+
53
+ ```ruby
54
+ class UsersIndexProcess
55
+ include BusinessPipeline::Process
56
+
57
+ step FetchAllUsers
58
+ step SortUsers
59
+ step PaginateUsers
60
+ end
61
+ ```
62
+
63
+ ### Defining Steps
64
+
65
+ So far so good. Now let write the Steps that our process uses:
66
+
67
+ ```ruby
68
+ class FetchAllUsers
69
+ include BusinessPipeline::Step
70
+
71
+ def call
72
+ context.users = User.all
73
+ end
74
+ end
75
+
76
+ class SortUsers
77
+ include BusinessPipeline::Step
78
+
79
+ def call
80
+ context.users = context.users.order(context.sort)
81
+ end
82
+ end
83
+
84
+ class PaginateUsers
85
+ include BusinessPipeline::Step
86
+
87
+ def call
88
+ context.users = context.user.page(context.page)
89
+ end
90
+ end
91
+ ```
92
+
93
+ Before we call our process, let's take a look at what we've got.
94
+
95
+ ### Nested processes
96
+
97
+ A Process is actually a super-Step. This means that a Process can call another Process like any other Step.
98
+
99
+ ```ruby
100
+ class UsersIndexProcess
101
+ include BusinessPipeline::Process
102
+
103
+ step ::IndexProcess
104
+ step UsersCustomStep
105
+ end
106
+ ```
107
+
108
+ In some occasions that may be a handy solution but you shouldn't need it most of the time.
109
+
110
+ ### Context
111
+
112
+ The first thing that you see is that every Step interacts with something called `context`.
113
+
114
+ This `context` is a _bag-of-data_ that is passed from one Step to another. It can be used as an object, just like we did with `context.users` but you can also use it like a Hash if necessary so here `context[:users]` would also work fine.
115
+
116
+ ```ruby
117
+ context.value = 42
118
+
119
+ context.value # => 42
120
+ context['value'] # => 42
121
+ context[:value] # => 42
122
+ ```
123
+
124
+ In our `SortUsers` and `PaginateUsers` Steps we used values from our _context_ that weren't define so far. We'll see how they came to be when looking at how to call a Process.
125
+
126
+ ### Calling a Process
127
+
128
+ When calling a Process, you can provide an initial _context_ by passing a Hash as argument:
129
+
130
+ ```ruby
131
+ UsersIndex.new.call(page: 1, sort: { created_at: :desc })
132
+ ```
133
+
134
+ You can then use this initial _context_ in all your Steps.
135
+
136
+ A Process returns the modified _context_ at the end of its execution. You can interrogate this _context_ to know if everything went according to plan:
137
+
138
+ ```ruby
139
+ result = UsersIndex.new.call(page: 1, sort: { created_at: :desc })
140
+
141
+ result.success? # => true
142
+ result.failure? # => false
143
+ result.users # => …sorted and paginated list of users…
144
+ ```
145
+
146
+ ## Going generic
147
+
148
+ So far we've written very narrow focused Processes and Steps but can we rewrite this code so that it becomes generic? Let's see how!
149
+
150
+ ### Process config
151
+
152
+ Before we get started we need to first take a look at a Process' initialization. It actually accepts a Hash that will act somewhat like `context` but should contain data that _define_ the Process as opposed to `context` that is more related to the _execution_ of the Process.
153
+
154
+ ```ruby
155
+ process = IndexProcess.new(collection_name: 'users', model_class: User)
156
+ process.call(page: 1, sort: { created_at: :desc })
157
+ ```
158
+
159
+ ### Generic Steps
160
+
161
+ Now that we initialized our Process with a _config_ let's change our Process' code and our Steps.
162
+
163
+ ```ruby
164
+ class IndexProcess
165
+ include BusinessPipeline::Process
166
+
167
+ step FetchAll
168
+ step Sort
169
+ step Paginate
170
+ end
171
+ ```
172
+
173
+ So far all we did is remove the `User` part of our class names, now we need to modify the code they contain to be indeed generic.
174
+
175
+ ```ruby
176
+ class FetchAll
177
+ include BusinessPipeline::Step
178
+
179
+ def call
180
+ collection_name = config.collection_name
181
+ model_class = config.model_class
182
+
183
+ context[collection_name] = model_class.all
184
+ end
185
+ end
186
+
187
+ class Sort
188
+ include BusinessPipeline::Step
189
+
190
+ def call
191
+ collection_name = config.collection_name
192
+
193
+ context[collection_name] = context[collection_name].order(context.sort)
194
+ end
195
+ end
196
+
197
+ class Paginate
198
+ include BusinessPipeline::Step
199
+
200
+ def call
201
+ collection_name = config.collection_name
202
+
203
+ context[collection_name] = context[collection_name].page(context.page)
204
+ end
205
+ end
206
+ ```
207
+
208
+ And done! Not bad actually. We leveraged the information passed to the Process config to be able to reuse our Steps for any type of resource.
209
+
210
+ ## Hooks
211
+
212
+ More often than not, you will want to implement things that are not quiet part of the business process per se, but are necessary for its good execution nonetheless. That's where _hooks_ come in handy.
213
+
214
+ _If you're used to Rails, hooks act like `around_action`, `before_action` and `after_action`._
215
+
216
+ Hooks can be defined on Processes and Steps alike. They accept blocks, method names or classes:
217
+
218
+ ```ruby
219
+ class IndexProcess
220
+ include BusinessPipeline::Process
221
+
222
+ around do |process, context, config|
223
+ puts "Calling process: #{process.class}"
224
+ puts "Config is: #{config.inspect}"
225
+
226
+ puts "Context before call is: #{context.inspect}"
227
+
228
+ process.call
229
+
230
+ puts "Context after call is: #{context.inspect}"
231
+ end
232
+
233
+ before :some_method
234
+
235
+ after SomeAwesomeClass
236
+
237
+ private def some_method(context, config)
238
+
239
+ end
240
+ end
241
+ ```
242
+
243
+ ### Hooks execution order
244
+
245
+ Execution of _around_ hooks will always be the first one. Then the _before_ hooks and to finish the _after_ ones. So writing the following Process
246
+
247
+ ```ruby
248
+ class IndexProcess
249
+ include BusinessPipeline::Process
250
+
251
+ around do |process|
252
+ puts 'AROUND 1 START'
253
+ process.call
254
+ puts 'AROUND 1 END'
255
+ end
256
+
257
+ before { puts 'BEFORE 1' }
258
+ before { puts 'BEFORE 2' }
259
+
260
+ around do |process|
261
+ puts 'AROUND 2 START'
262
+ process.call
263
+ puts 'AROUND 2 END'
264
+ end
265
+
266
+ after { puts 'AFTER 1' }
267
+ after { puts 'AFTER 2' }
268
+ end
269
+ ```
270
+
271
+ Would result in the following output:
272
+
273
+ ```
274
+ AROUND 1 START
275
+ AROUND 2 START
276
+ BEFORE 1
277
+ BEFORE 2
278
+ AFTER 1
279
+ AFTER 2
280
+ AROUND 2 END
281
+ AROUND 1 END
282
+ ```
283
+
284
+ ### Hooks inheritence
285
+
286
+ If you inherit from a Process, your new class will inherit the hooks from its parent Process. This is especially useful when you want to centralize specific behaviors across all Processes.
287
+
288
+ If for instance you wanted to wrap every Process in a transaction (which would be a good idea by the way :wink:), you can define it this way:
289
+
290
+ ```ruby
291
+ class TransactionWrapping
292
+ def call(process, context, config)
293
+ ActiveRecord::Base.transaction { process.call }
294
+ end
295
+ end
296
+
297
+ class ApplicationProcess
298
+ include BusinessPipeline::Process
299
+
300
+ around TransactionWrapping
301
+ end
302
+
303
+ class IndexProcess < ApplicationProcess
304
+ # …
305
+ end
306
+ ```
307
+
308
+ Calling your `IndexProcess` will wrap it in a SQL transaction :heart_eyes:.
309
+
310
+ ## Process config configuration
311
+
312
+ If you go generic all the way, you may end-up with interesting Steps like this:
313
+
314
+ ```ruby
315
+ class ExtractAttribute
316
+ include BusinessPipeline::Step
317
+
318
+ def call
319
+ attribute_name = config.attribute_name
320
+ expose_as = config.expose_as
321
+ source = config.source
322
+
323
+ context[expose_as] = context[source].public_send(attribute_name)
324
+ end
325
+ end
326
+ ```
327
+
328
+ If for instance you have a `user` in your _context_ and you wanted to expose its `email` attribute as `user_email` the _config_ needed for this to happen would be:
329
+
330
+ ```ruby
331
+ { source: :user, attribute_name: :email, expose_as: :user_email }
332
+ ```
333
+
334
+ And the whole Step to translate to
335
+
336
+ ```ruby
337
+ class ExtractAttribute
338
+ include BusinessPipeline::Step
339
+
340
+ def call
341
+ context.user_email = context.user.email
342
+ end
343
+ end
344
+ ```
345
+
346
+ Now what happens if you need to call this generic step for several attributes?
347
+
348
+ Luckily, you can override the Process' _config_ when using a Step and this override will only be active for this specific Step:
349
+
350
+ ```ruby
351
+ class SomeUserProcess < ApplicationProcess
352
+ step FindUser
353
+
354
+ step ExtractAttribute do
355
+ source :user
356
+ attribute_name :email
357
+ expose_as :user_email
358
+ end
359
+
360
+ step ExtractAttribute do
361
+ source :user
362
+ attribute_name :lastname
363
+ expose_as :user_lastname
364
+ end
365
+ end
366
+ ```
367
+
368
+ At the end of the Process' execution, the context will contain `user_email` and `user_lastname` with the corresponding values.
369
+
370
+ ## Returning early
371
+
372
+ Very often you may need to stop the execution of a Process. This can happen for two reasons: an error or an early success.
373
+
374
+ ### Errors
375
+
376
+ If you want to stop the process execution because a situation makes it impossible to continue, you can leverage the `fail!` method.
377
+
378
+ ```ruby
379
+ class DataCheck
380
+ include BusinessPipeline::Step
381
+
382
+ def call
383
+ context.continue == 'yes' || fail!(error: 'Continue is not set to yes')
384
+ end
385
+ end
386
+
387
+ class CheckingProcess
388
+ include BusinessPipeline::Process
389
+
390
+ step DataCheck
391
+ end
392
+ ```
393
+
394
+ Calling `context.fail!` will stop the execution and merge the information you give it to the _context_.
395
+
396
+ ```ruby
397
+ result = CheckingProcess.new.call(continue: 'no')
398
+
399
+ result.success? # => false
400
+ result.failure? # => true
401
+ result.error # => 'Continue is not set to yes'
402
+ ```
403
+
404
+ ### Early success
405
+
406
+ Sometimes, you may want to stop the execution of a Process because of an early success. That's what the `succeed!` method is for.
407
+
408
+ For instance if you want to have a _find or create_ behavior you could implement it this way:
409
+
410
+ ```ruby
411
+ class UserCreationProcess
412
+ include BusinessPipeline::Process
413
+
414
+ step FindUser
415
+ step CreateUser
416
+ step SendAccountConfirmationEmail
417
+ end
418
+
419
+ class FindUser
420
+ include BusinessPipeline::Step
421
+
422
+ def call
423
+ user = User.find(context.user_id)
424
+ succeed!(user: user) if user
425
+ end
426
+ end
427
+ ```
428
+
429
+ ## Development
430
+
431
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
432
+
433
+ 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).
434
+
435
+ ## Contributing
436
+
437
+ Bug reports and pull requests are welcome on GitHub at https://github.com/simonc/business_pipeline. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
438
+
439
+ ## License
440
+
441
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
442
+
443
+ ## Code of Conduct
444
+
445
+ Everyone interacting in the BusinessPipeline project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/simonc/business_pipeline/blob/master/CODE_OF_CONDUCT.md).
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+
5
+ task default: :spec
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'business_pipeline'
5
+
6
+ require 'irb'
7
+ IRB.start(__FILE__)
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+ IFS=$'\n\t'
5
+ set -vx
6
+
7
+ bundle install
8
+
9
+ cat > .git/hooks/pre-commit <<EOF
10
+ #!/usr/bin/env sh
11
+
12
+ unset GIT_DIR
13
+
14
+ bundle exec rubocop -D
15
+ if [ \$? -eq 1 ]; then exit 1; fi
16
+ EOF
17
+
18
+ chmod +x .git/hooks/pre-commit
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'business_pipeline/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'business_pipeline'
9
+ spec.version = BusinessPipeline::VERSION
10
+ spec.authors = ['Simon Courtois']
11
+ spec.email = ['scourtois_github@cubyx.fr']
12
+
13
+ spec.summary = 'A new business pipeline architecture for Rails applications'
14
+ spec.license = 'MIT'
15
+
16
+ # Specify which files should be added to the gem when it is released.
17
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
18
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
19
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
20
+ end
21
+ spec.bindir = 'exe'
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_development_dependency 'bundler', '~> 1.16'
26
+ spec.add_development_dependency 'guard-rspec', '~> 4.7'
27
+ spec.add_development_dependency 'rake', '~> 10.0'
28
+ spec.add_development_dependency 'rspec', '~> 3.6'
29
+ spec.add_development_dependency 'rubocop', '~> 0.60.0'
30
+ spec.add_development_dependency 'simplecov', '~> 0.16.1'
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'business_pipeline'
4
+
5
+ BP = BusinessPipeline
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'business_pipeline/process'
4
+ require 'business_pipeline/step'
5
+ require 'business_pipeline/version'
6
+
7
+ # BusinessPipeline provides a new organisation for your Rails application.
8
+ module BusinessPipeline
9
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+
5
+ module BusinessPipeline
6
+ class Config < OpenStruct
7
+ def initialize(hash = nil, &block)
8
+ super(hash)
9
+ instance_eval(&block) if block
10
+ end
11
+
12
+ def fetch(key)
13
+ value = self[key.to_sym]
14
+
15
+ return value unless value.nil?
16
+ return yield(key) if block_given?
17
+
18
+ fail KeyError, key
19
+ end
20
+
21
+ # rubocop:disable Style/MissingRespondToMissing
22
+ def method_missing(meth, *args, &block)
23
+ if args.size.zero? || meth.to_s.end_with?('=')
24
+ super
25
+ else
26
+ self[meth] = args.first
27
+ end
28
+ end
29
+ # rubocop:enable Style/MissingRespondToMissing
30
+
31
+ attr_reader :data
32
+ private :data
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+
5
+ module BusinessPipeline
6
+ class Context < OpenStruct
7
+ def self.build(context = {})
8
+ context.is_a?(self) ? context : new(context)
9
+ end
10
+
11
+ def initialize(*)
12
+ super
13
+ @failure = false
14
+ end
15
+
16
+ def fail
17
+ @failure = true
18
+ end
19
+
20
+ def fail!(additional_context = {})
21
+ update!(additional_context)
22
+ self.fail
23
+ throw :early_stop, self
24
+ end
25
+
26
+ def failure?
27
+ !!@failure
28
+ end
29
+
30
+ def succeed!(additional_context = {})
31
+ update!(additional_context)
32
+ throw :early_stop, self
33
+ end
34
+
35
+ def success?
36
+ !failure?
37
+ end
38
+
39
+ private def update!(context)
40
+ context.each { |key, value| modifiable[key.to_sym] = value }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BusinessPipeline
4
+ module Hooks
5
+ def self.included(base)
6
+ base.class_eval do
7
+ extend ClassMethods
8
+ end
9
+ end
10
+
11
+ module ClassMethods
12
+ def add_hooks(*new_hooks, type: __callee__, &block)
13
+ hooks[type] += [*new_hooks, block].compact.map do |hook|
14
+ hook.respond_to?(:new) ? hook.new : hook
15
+ end
16
+ end
17
+ alias_method :after, :add_hooks
18
+ alias_method :around, :add_hooks
19
+ alias_method :before, :add_hooks
20
+
21
+ def hooks
22
+ @hooks ||= { after: [], around: [], before: [] }
23
+ end
24
+ end
25
+
26
+ private def run_around_hooks(&block)
27
+ around_hooks = self.class.hooks[:around]
28
+
29
+ around_hooks
30
+ .reverse
31
+ .inject(block) { |chain, hook| proc { run_hook(hook, chain) } }
32
+ .call
33
+ end
34
+
35
+ private def with_hooks
36
+ run_around_hooks do
37
+ run_hooks :before
38
+ yield
39
+ run_hooks :after
40
+ end
41
+ end
42
+
43
+ private def run_hooks(type)
44
+ self.class.hooks[type].each { |hook| run_hook(hook) }
45
+ end
46
+
47
+ private def run_hook(hook, *args)
48
+ hook = method(hook) if hook.is_a?(Symbol)
49
+ hook.call(*args, context, config)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'business_pipeline/config'
4
+
5
+ module BusinessPipeline
6
+ module Process
7
+ def self.included(base)
8
+ base.class_eval do
9
+ include BusinessPipeline::Step
10
+
11
+ extend ClassMethods
12
+ include InstanceMethods
13
+ end
14
+ end
15
+
16
+ module InstanceMethods
17
+ def call
18
+ self.class.steps.each do |step_class, block|
19
+ step_config = BusinessPipeline::Config.new(config, &block)
20
+ step = step_class.new(step_config)
21
+ step.perform(context)
22
+ end
23
+ end
24
+
25
+ def perform(context = {})
26
+ config._processes ||= []
27
+ config._processes << self
28
+
29
+ config._processes.one? ? catch(:early_stop) { super } : super
30
+ ensure
31
+ config._processes.pop
32
+ end
33
+ end
34
+
35
+ module ClassMethods
36
+ def step(step_class, &block)
37
+ steps << [step_class, block]
38
+ end
39
+
40
+ def steps
41
+ @steps ||= []
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'business_pipeline/config'
4
+ require 'business_pipeline/context'
5
+ require 'business_pipeline/hooks'
6
+
7
+ module BusinessPipeline
8
+ module Step
9
+ def self.included(base)
10
+ base.class_eval do
11
+ include Hooks
12
+
13
+ attr_reader :context
14
+ private :context
15
+
16
+ attr_reader :config
17
+ private :config
18
+
19
+ def self.inherited(child_class)
20
+ child_class.instance_variable_set(:@hooks, hooks)
21
+ end
22
+ end
23
+ end
24
+
25
+ def initialize(config = {})
26
+ @config = BusinessPipeline::Config.new(config)
27
+ end
28
+
29
+ def call
30
+ fail NotImplementedError
31
+ end
32
+
33
+ def fail!(additional_context = {})
34
+ context.fail!(additional_context)
35
+ end
36
+
37
+ def perform(context = {})
38
+ @context = BusinessPipeline::Context.build(context)
39
+ with_hooks { call }
40
+ @context
41
+ end
42
+
43
+ def succeed!(additional_context = {})
44
+ context.succeed!(additional_context)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BusinessPipeline
4
+ VERSION = -'0.1.0'
5
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: business_pipeline
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Courtois
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-02-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: guard-rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '4.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '4.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.6'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.60.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.60.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.16.1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.16.1
97
+ description:
98
+ email:
99
+ - scourtois_github@cubyx.fr
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - ".rspec"
106
+ - ".rubocop.yml"
107
+ - CODE_OF_CONDUCT.md
108
+ - Gemfile
109
+ - Gemfile.lock
110
+ - Guardfile
111
+ - LICENSE.txt
112
+ - README.md
113
+ - Rakefile
114
+ - bin/console
115
+ - bin/setup
116
+ - business_pipeline.gemspec
117
+ - lib/bp.rb
118
+ - lib/business_pipeline.rb
119
+ - lib/business_pipeline/config.rb
120
+ - lib/business_pipeline/context.rb
121
+ - lib/business_pipeline/hooks.rb
122
+ - lib/business_pipeline/process.rb
123
+ - lib/business_pipeline/step.rb
124
+ - lib/business_pipeline/version.rb
125
+ homepage:
126
+ licenses:
127
+ - MIT
128
+ metadata: {}
129
+ post_install_message:
130
+ rdoc_options: []
131
+ require_paths:
132
+ - lib
133
+ required_ruby_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ required_rubygems_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ requirements: []
144
+ rubyforge_project:
145
+ rubygems_version: 2.7.6
146
+ signing_key:
147
+ specification_version: 4
148
+ summary: A new business pipeline architecture for Rails applications
149
+ test_files: []