codequest_pipes 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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 [![Build Status](https://travis-ci.org/codequest-eu/codequest_pipes.svg?branch=master)](https://travis-ci.org/codequest-eu/codequest_pipes) [![codebeat badge](https://codebeat.co/badges/73f1bb7f-516f-4fc5-b241-daea42c7badd)](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
|