codequest_pipes 0.2.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.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +7 -0
- data/LICENSE +22 -0
- data/README.md +57 -0
- data/Rakefile +9 -0
- data/codequest_pipes.gemspec +19 -0
- data/lib/codequest_pipes/closure.rb +9 -0
- data/lib/codequest_pipes/context.rb +61 -0
- data/lib/codequest_pipes/pipe.rb +76 -0
- data/lib/codequest_pipes.rb +8 -0
- data/spec/context_spec.rb +18 -0
- data/spec/pipe_spec.rb +127 -0
- data/spec/spec_helper.rb +4 -0
- metadata +89 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 5de4743e4f4364742d5f485f244167f604394777
|
4
|
+
data.tar.gz: 76c3e6315db9137dac15c76a0263b177855295c3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f5059e69133b70d4c618334e1fef64eb603e54b90e86df911ed22aa4b7f05a61542089d85f86d42f22789ece92ff0ccf9e83ddcb69cc94af27da479fe2bc4660
|
7
|
+
data.tar.gz: b3589d64194030087e2df1a7115e65956683a4b55e4dfcfde8b144aa320c27139baaa897efce8743c38b30c399d1598759f7db9e41d9e8852db0da8c9da5a07d
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 code quest
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
22
|
+
|
data/README.md
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# Pipes [](https://travis-ci.org/codequest-eu/codequest_pipes) [](https://codebeat.co/projects/codequest_pipes-master-ab1a7c5f-ad5f-425a-a0f0-e56e13a04876)
|
2
|
+
|
3
|
+
Pipes provide a Unix-like way to chain business objects (interactors) in Ruby.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
To start using Pipes, add the library to your Gemfile. This project is currently
|
8
|
+
not available on RubyGems.
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem "codequest_pipes", github: "codequest-eu/codequest_pipes"
|
12
|
+
```
|
13
|
+
|
14
|
+
## High-level usage example
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
FLOW = Projects::Match | # NOTE: each of the elements must inherit from
|
18
|
+
Projects::Validate | # Pipes::Pipe!
|
19
|
+
Projects::UpdatePayment |
|
20
|
+
Projects::SaveWithReport
|
21
|
+
context = Pipes::Context.new(project: p)
|
22
|
+
FLOW.call(context)
|
23
|
+
```
|
24
|
+
|
25
|
+
## Pipe
|
26
|
+
|
27
|
+
Pipes provide a way to describe business transactions in a stateless
|
28
|
+
and reusable way. Let's create a few pipes from plain Ruby classes.
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
class PaymentPipe < Pipes::Pipe
|
32
|
+
require_context :user # flow will fail if precondition not met
|
33
|
+
provide_context :transaction # flow will fail if postcondition not met
|
34
|
+
|
35
|
+
def call
|
36
|
+
result = PaymentService.create_transaction(user)
|
37
|
+
add(transaction: result.transaction)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
```
|
41
|
+
|
42
|
+
Note how we've only had to implement the `call` method for the magic to start happening. When calling these objects you'd be using the class method `call` instead and passing a `Pipes::Context` objects to it. All unknown messages (like `user`) in two examples above are passed to the `context` which is an instance variable of every object inheriting from `Pipes::Pipe`.
|
43
|
+
|
44
|
+
## Context
|
45
|
+
|
46
|
+
Each Pipe requires an instance `Pipes::Context` to be passed on `.call` invokation. It provides append-only data container for Pipes: you can add data to a context at any time using the `add` method but the same call will raise an error if you try to modify an existing key.
|
47
|
+
|
48
|
+
Made with <3 by [code quest](http://www.codequest.com)
|
49
|
+
|
50
|
+
|
51
|
+
|
52
|
+
|
53
|
+
|
54
|
+
|
55
|
+
|
56
|
+
|
57
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = 'codequest_pipes'
|
5
|
+
spec.version = '0.2.0'
|
6
|
+
|
7
|
+
spec.author = 'codequest'
|
8
|
+
spec.email = 'hello@codequest.com'
|
9
|
+
spec.description = 'Pipes provides a Unix-like way to chain business objects'
|
10
|
+
spec.summary = 'Unix-like way to chain business objects (interactors)'
|
11
|
+
spec.homepage = 'https://github.com/codequest-eu/codequest_pipes'
|
12
|
+
spec.license = 'MIT'
|
13
|
+
|
14
|
+
spec.files = `git ls-files`.split($RS)
|
15
|
+
spec.test_files = spec.files.grep(/^spec/)
|
16
|
+
|
17
|
+
spec.add_development_dependency 'bundler', '>= 1.6.9'
|
18
|
+
spec.add_development_dependency 'rake', '~> 10.3'
|
19
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Pipes
|
2
|
+
# Context is an object used to pass data between Pipes. It behaves like an
|
3
|
+
# OpenStruct except you can write a value only once - this way we prevent
|
4
|
+
# context keys from being overwritten.
|
5
|
+
class Context
|
6
|
+
attr_reader :error
|
7
|
+
|
8
|
+
# Override is an exception raised when an attempt is made to override an
|
9
|
+
# existing Context property.
|
10
|
+
class Override < ::StandardError; end
|
11
|
+
|
12
|
+
# ExecutionTerminated is an exception raised when the `fail` method is
|
13
|
+
# explicitly called on the Context. This terminates the flow of a pipe.
|
14
|
+
class ExecutionTerminated < ::StandardError; end
|
15
|
+
|
16
|
+
# Context constructor.
|
17
|
+
#
|
18
|
+
# @param values [Hash]
|
19
|
+
def initialize(values = {})
|
20
|
+
add(values)
|
21
|
+
@error = nil
|
22
|
+
end
|
23
|
+
|
24
|
+
# Method `add` allows adding new properties (as a Hash) to the Context.
|
25
|
+
#
|
26
|
+
# @param values [Hash]
|
27
|
+
def add(values)
|
28
|
+
values.each do |key, val|
|
29
|
+
k_sym = key.to_sym
|
30
|
+
fail Override, "Property :#{key} already present" if respond_to?(k_sym)
|
31
|
+
define_singleton_method(k_sym) { val }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Explicitly fail the pipe, allowing the error to be saved and accessed from
|
36
|
+
# the Context.
|
37
|
+
#
|
38
|
+
# @param error [Any] Error to be set.
|
39
|
+
#
|
40
|
+
# @raise [ExecutionTerminated]
|
41
|
+
def terminate(error)
|
42
|
+
@error = error
|
43
|
+
fail ExecutionTerminated, error
|
44
|
+
end
|
45
|
+
|
46
|
+
# Check if the Context finished successfully.
|
47
|
+
# This method smells of :reek:NilCheck
|
48
|
+
#
|
49
|
+
# @return [Boolean] Success status.
|
50
|
+
def success?
|
51
|
+
@error.nil?
|
52
|
+
end
|
53
|
+
|
54
|
+
# Check if the Context failed.
|
55
|
+
#
|
56
|
+
# @return [Boolean] Failure status.
|
57
|
+
def failure?
|
58
|
+
!success?
|
59
|
+
end
|
60
|
+
end # class Context
|
61
|
+
end # module Pipes
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Pipes
|
2
|
+
# Pipe is a mix-in which turns a class into a Pipes building block (Pipe).
|
3
|
+
# A Pipe can only have class methods since it can't be instantiated.
|
4
|
+
class Pipe
|
5
|
+
attr_reader :context
|
6
|
+
|
7
|
+
def initialize(ctx)
|
8
|
+
@context = ctx
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
fail MissingCallMethod
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.|(other)
|
16
|
+
this = self
|
17
|
+
Class.new(Pipe) do
|
18
|
+
_combine(this, other)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.call(ctx)
|
23
|
+
_validate_ctx(_required_context_elements, ctx)
|
24
|
+
new(ctx).call
|
25
|
+
_validate_ctx(_provided_context_elements, ctx)
|
26
|
+
ctx
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.require_context(*args)
|
30
|
+
_required_context_elements.push(*args)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.provide_context(*args)
|
34
|
+
_provided_context_elements.push(*args)
|
35
|
+
end
|
36
|
+
|
37
|
+
def self._combine(first, second)
|
38
|
+
_check_interface(first)
|
39
|
+
_check_interface(second)
|
40
|
+
define_singleton_method(:call) do |ctx|
|
41
|
+
first.call(ctx)
|
42
|
+
second.call(ctx)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
private_class_method :_combine
|
46
|
+
|
47
|
+
def self._check_interface(klass)
|
48
|
+
fail MissingCallMethod unless klass.instance_methods.include?(:call)
|
49
|
+
end
|
50
|
+
private_class_method :_check_interface
|
51
|
+
|
52
|
+
def self._required_context_elements
|
53
|
+
@required_context_elements ||= []
|
54
|
+
end
|
55
|
+
private_class_method :_required_context_elements
|
56
|
+
|
57
|
+
def self._provided_context_elements
|
58
|
+
@provided_context_elements ||= []
|
59
|
+
end
|
60
|
+
private_class_method :_provided_context_elements
|
61
|
+
|
62
|
+
def self._validate_ctx(collection, ctx)
|
63
|
+
collection.each do |element|
|
64
|
+
next if ctx.respond_to?(element)
|
65
|
+
fail MissingContext, "context does not respond to '#{element}'"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
private_class_method :_validate_ctx
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def method_missing(name, *args, &block)
|
73
|
+
context.send(name, *args, &block)
|
74
|
+
end
|
75
|
+
end # class Pipe
|
76
|
+
end # module Pipes
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Pipes::Context do
|
4
|
+
let(:ctx) { Pipes::Context.new }
|
5
|
+
|
6
|
+
describe '#add' do
|
7
|
+
it 'allows adding new fields' do
|
8
|
+
ctx.add(key: 'val')
|
9
|
+
expect(ctx.key).to eq('val')
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'does not allow rewriting existing fields' do
|
13
|
+
ctx.add(key: 'val')
|
14
|
+
expect { ctx.add(key: 'other_val') }
|
15
|
+
.to raise_error(Pipes::Context::Override)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end # describe Context
|
data/spec/pipe_spec.rb
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
# Parent is a dummy test class.
|
4
|
+
class Parent < Pipes::Pipe
|
5
|
+
require_context :flow
|
6
|
+
|
7
|
+
def call
|
8
|
+
flow.push(self.class.name)
|
9
|
+
end
|
10
|
+
end # class Parent
|
11
|
+
|
12
|
+
class Child < Parent; end
|
13
|
+
class Grandchild < Parent; end
|
14
|
+
class GrandGrandchild < Parent; end
|
15
|
+
|
16
|
+
# BadApple will break.
|
17
|
+
class BadApple < Pipes::Pipe
|
18
|
+
def call
|
19
|
+
fail StandardError
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# NoMethodPipe will break with NoMethodError.
|
24
|
+
class NoMethodPipe < Pipes::Pipe; end
|
25
|
+
|
26
|
+
describe Pipes::Pipe do
|
27
|
+
let(:ctx) { Pipes::Context.new(flow: []) }
|
28
|
+
|
29
|
+
subject { pipe.call(ctx) }
|
30
|
+
|
31
|
+
describe 'normal flow' do
|
32
|
+
let(:pipe) { Parent | Child | Grandchild }
|
33
|
+
|
34
|
+
it 'executes the pipe left to right' do
|
35
|
+
expect { subject }.to change { ctx.flow }
|
36
|
+
.from([]).to %w(Parent Child Grandchild)
|
37
|
+
end
|
38
|
+
end # describe 'normal flow'
|
39
|
+
|
40
|
+
describe 'pipe raising an exception' do
|
41
|
+
let(:pipe) { Parent | BadApple | Child }
|
42
|
+
|
43
|
+
it 'raises StandardError' do
|
44
|
+
expect { pipe.call(ctx) }.to raise_error StandardError
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'stores the result of execution so far in the context' do
|
48
|
+
# rubocop:disable Style/RescueModifier
|
49
|
+
expect { pipe.call(ctx) rescue nil }
|
50
|
+
.to change { ctx.flow }
|
51
|
+
.from([]).to(['Parent'])
|
52
|
+
# rubocop:enable Style/RescueModifier
|
53
|
+
end
|
54
|
+
end # describe 'pipe raising an exception'
|
55
|
+
|
56
|
+
describe '.provide_context' do
|
57
|
+
context 'when context element provided' do
|
58
|
+
class ProvidingChild < Parent
|
59
|
+
provide_context :bacon
|
60
|
+
|
61
|
+
def call
|
62
|
+
super
|
63
|
+
add(bacon: true)
|
64
|
+
end
|
65
|
+
end # class ProvideChild
|
66
|
+
|
67
|
+
let(:pipe) { Parent | ProvidingChild }
|
68
|
+
|
69
|
+
it 'does not raise' do
|
70
|
+
expect { subject }.to_not raise_error
|
71
|
+
end
|
72
|
+
end # context 'when context element provided'
|
73
|
+
|
74
|
+
context 'when context element not provided' do
|
75
|
+
class NotProvidingChild < Parent
|
76
|
+
provide_context :bacon
|
77
|
+
end
|
78
|
+
|
79
|
+
let(:pipe) { Parent | NotProvidingChild }
|
80
|
+
|
81
|
+
it 'raises MissingContext' do
|
82
|
+
expect { subject }.to raise_error Pipes::MissingContext
|
83
|
+
end
|
84
|
+
end # context 'when context element not provided'
|
85
|
+
end # describe '.provide_context'
|
86
|
+
|
87
|
+
describe 'pipes declared using Pipe::Closure' do
|
88
|
+
let(:dynamic_grandchild) do
|
89
|
+
Pipes::Closure.define { context.flow << 'bacon' }
|
90
|
+
end
|
91
|
+
let(:pipe) { Parent | dynamic_grandchild | Child }
|
92
|
+
|
93
|
+
it 'behaves as with normal pipes' do
|
94
|
+
expect { subject }
|
95
|
+
.to change { ctx.flow }
|
96
|
+
.from([]).to %w(Parent bacon Child)
|
97
|
+
end
|
98
|
+
end # describe 'pipes declared using Pipe::Closure'
|
99
|
+
|
100
|
+
describe 'pipe with a missing `call` method' do
|
101
|
+
let(:pipe) { Parent | Child | NoMethodPipe }
|
102
|
+
|
103
|
+
it 'raises a Pipes::MissingCallMethod error' do
|
104
|
+
expect { subject }.to raise_error Pipes::MissingCallMethod
|
105
|
+
end
|
106
|
+
end # describe 'pipe with a missing `call` method'
|
107
|
+
|
108
|
+
describe 'combined pipes' do
|
109
|
+
let(:first) { Parent | Child }
|
110
|
+
let(:second) { Grandchild | GrandGrandchild }
|
111
|
+
let(:pipe) { first | second }
|
112
|
+
|
113
|
+
it 'behaves as with normal pipes' do
|
114
|
+
expect { subject }
|
115
|
+
.to change { ctx.flow }
|
116
|
+
.from([]).to %w(Parent Child Grandchild GrandGrandchild)
|
117
|
+
end
|
118
|
+
|
119
|
+
describe 'broken combination' do
|
120
|
+
let(:second) { NoMethodPipe | Grandchild }
|
121
|
+
|
122
|
+
it 'raises error from a broken pipe' do
|
123
|
+
expect { subject }.to raise_error Pipes::MissingCallMethod
|
124
|
+
end
|
125
|
+
end # describe 'broken combination'
|
126
|
+
end # describe 'combined pipes'
|
127
|
+
end # describe Pipes::Pipe
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: codequest_pipes
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- codequest
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-02-13 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.6.9
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.6.9
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.3'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.3'
|
41
|
+
description: Pipes provides a Unix-like way to chain business objects
|
42
|
+
email: hello@codequest.com
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files: []
|
46
|
+
files:
|
47
|
+
- ".gitignore"
|
48
|
+
- ".rspec"
|
49
|
+
- ".travis.yml"
|
50
|
+
- Gemfile
|
51
|
+
- LICENSE
|
52
|
+
- README.md
|
53
|
+
- Rakefile
|
54
|
+
- codequest_pipes.gemspec
|
55
|
+
- lib/codequest_pipes.rb
|
56
|
+
- lib/codequest_pipes/closure.rb
|
57
|
+
- lib/codequest_pipes/context.rb
|
58
|
+
- lib/codequest_pipes/pipe.rb
|
59
|
+
- spec/context_spec.rb
|
60
|
+
- spec/pipe_spec.rb
|
61
|
+
- spec/spec_helper.rb
|
62
|
+
homepage: https://github.com/codequest-eu/codequest_pipes
|
63
|
+
licenses:
|
64
|
+
- MIT
|
65
|
+
metadata: {}
|
66
|
+
post_install_message:
|
67
|
+
rdoc_options: []
|
68
|
+
require_paths:
|
69
|
+
- lib
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - ">="
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '0'
|
80
|
+
requirements: []
|
81
|
+
rubyforge_project:
|
82
|
+
rubygems_version: 2.5.1
|
83
|
+
signing_key:
|
84
|
+
specification_version: 4
|
85
|
+
summary: Unix-like way to chain business objects (interactors)
|
86
|
+
test_files:
|
87
|
+
- spec/context_spec.rb
|
88
|
+
- spec/pipe_spec.rb
|
89
|
+
- spec/spec_helper.rb
|