linearly 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,190 @@
1
+ require 'singleton'
2
+
3
+ module Linearly
4
+ # {Validation} provides a way to check inputs and outputs against a set of
5
+ # per-field expectations.
6
+ # @abstract
7
+ class Validation
8
+ # Constructor for a {Validation}
9
+ #
10
+ # @param expectations [Hash<Symbol, Expectation>] a hash of per-field
11
+ # expectations. An expectation can be +true+ (just checking for field
12
+ # presence), a class name (checking for value type) or a +Proc+
13
+ # taking a value and returning a +Boolean+.
14
+ #
15
+ # @api private
16
+ def initialize(expectations)
17
+ @expectations =
18
+ expectations
19
+ .map { |key, expectation| [key, Expectation.to_proc(expectation)] }
20
+ .to_h
21
+ end
22
+
23
+ # Call validation with a {State}
24
+ #
25
+ # @param state [Statefully::State]
26
+ #
27
+ # @return [Statefully::State]
28
+ # @api private
29
+ def call(state)
30
+ Validator
31
+ .new(expectations, state)
32
+ .validate(error_class)
33
+ end
34
+
35
+ private
36
+
37
+ # Wrapped expectations
38
+ #
39
+ # @return [Hash<Symbol, Expectation>]
40
+ # @api private
41
+ attr_reader :expectations
42
+
43
+ # {Inputs} is a pre-flight {Validation} of {State} inputs
44
+ class Inputs < Validation
45
+ private
46
+
47
+ # Return associated error class
48
+ #
49
+ # @return [Class]
50
+ # @api private
51
+ def error_class
52
+ Errors::BrokenContract::Inputs
53
+ end
54
+ end # class Inputs
55
+
56
+ # {Inputs} is a post-flight {Validation} of {State} outputs
57
+ class Outputs < Validation
58
+ private
59
+
60
+ # Return associated error class
61
+ #
62
+ # @return [Class]
63
+ # @api private
64
+ def error_class
65
+ Errors::BrokenContract::Outputs
66
+ end
67
+ end # class Outputs
68
+
69
+ # {Validator} is a stateful helper applying expecations to a {State}
70
+ class Validator
71
+ # Constructor method for a {Validator}
72
+ #
73
+ # @param expectations [Hash<Symbol, Expectation>]
74
+ # @param state [Statefully::State]
75
+ #
76
+ # @api private
77
+ def initialize(expectations, state)
78
+ @expectations = expectations
79
+ @state = state
80
+ end
81
+
82
+ # Validate wrapped {State}, failing it with an error class if needed
83
+ #
84
+ # @param error_class [Class]
85
+ #
86
+ # @return [Statefully::State]
87
+ # @api private
88
+ def validate(error_class)
89
+ failures = invalid.merge(missing).freeze
90
+ return @state if failures.empty?
91
+ @state.fail(error_class.new(failures))
92
+ end
93
+
94
+ private
95
+
96
+ # Return the invalid fields
97
+ #
98
+ # @return [Hash<Field, Failure::Unexpected>]
99
+ # @api private
100
+ def invalid
101
+ @invalid ||= @expectations.map do |key, expectation|
102
+ next nil if missing.key?(key)
103
+ value = @state.fetch(key)
104
+ next nil if expectation.call(value)
105
+ [key, Failure::Unexpected.instance]
106
+ end.compact.to_h
107
+ end
108
+
109
+ # Return the missing fields
110
+ #
111
+ # @return [Hash<Field, Failure::Missing>]
112
+ # @api private
113
+ def missing
114
+ @missing ||=
115
+ @expectations
116
+ .keys
117
+ .reject { |key| @state.key?(key) }
118
+ .map { |key| [key, Failure::Missing.instance] }
119
+ .to_h
120
+ end
121
+ end # class Validator
122
+ private_constant :Validator
123
+
124
+ # {Failure} is a representation of a problem encountered when validating a
125
+ # single {State} field.
126
+ class Failure
127
+ include Singleton
128
+
129
+ # Human-readable representation of the {Failure}
130
+ #
131
+ # @return [String]
132
+ # @api public
133
+ # @example
134
+ # Linearly::Validation::Failure::Missing.instance.missing?
135
+ # => [missing]
136
+ def inspect
137
+ "[#{self.class.name.split('::').last.downcase}]"
138
+ end
139
+
140
+ # {Unexpected} is a type of {Failure} when a field does not exists
141
+ class Missing < Failure
142
+ # Check if the field is missing
143
+ #
144
+ # @return [FalseClass]
145
+ # @api public
146
+ # @example
147
+ # Linearly::Validation::Failure::Missing.instance.missing?
148
+ # => true
149
+ def missing?
150
+ true
151
+ end
152
+ end # class Missing
153
+
154
+ # {Unexpected} is a type of {Failure} when a field exists, but its value
155
+ # does not match expectations.
156
+ class Unexpected < Failure
157
+ # Check if the field is missing
158
+ #
159
+ # @return [TrueClass]
160
+ # @api public
161
+ # @example
162
+ # Linearly::Validation::Failure::Unexpected.instance.missing?
163
+ # => true
164
+ def missing?
165
+ false
166
+ end
167
+ end # class Unexpected
168
+ end # class Failure
169
+
170
+ # {Expectation} is a helper module to turn various types of expectations
171
+ # into {Proc}s.
172
+ module Expectation
173
+ # Turn one of the supported expecation types into a Proc
174
+ #
175
+ # This method reeks of :reek:TooManyStatements.
176
+ #
177
+ # @param expectation [Symbol|Class|Proc]
178
+ #
179
+ # @return [Proc]
180
+ # @api private
181
+ def to_proc(expectation)
182
+ klass = expectation.class
183
+ return ->(value) { value.is_a?(expectation) } if klass == Class
184
+ return ->(_) { true } if klass == TrueClass
185
+ expectation
186
+ end
187
+ module_function :to_proc
188
+ end # module Expectation
189
+ end # class Validation
190
+ end # module Linearly
@@ -0,0 +1,3 @@
1
+ module Linearly
2
+ VERSION = '0.1.0'.freeze
3
+ end # module Linearly
data/lib/linearly.rb ADDED
@@ -0,0 +1,12 @@
1
+ require 'linearly/mixins/flow_builder'
2
+ require 'linearly/mixins/reducer'
3
+ require 'linearly/errors/broken_contract'
4
+ require 'linearly/errors/state_not_returned'
5
+ require 'linearly/flow'
6
+ require 'linearly/runner'
7
+ require 'linearly/step/dynamic'
8
+ require 'linearly/step/static'
9
+ require 'linearly/validation'
10
+ require 'linearly/version'
11
+
12
+ require 'statefully'
@@ -0,0 +1,15 @@
1
+ module Linearly
2
+ class DynamicStep
3
+ include Step::Dynamic
4
+
5
+ class Valid < DynamicStep
6
+ def inputs
7
+ {}
8
+ end
9
+
10
+ def outputs
11
+ {}
12
+ end
13
+ end # class Valid
14
+ end # class DynamicStep
15
+ end # module Linearly
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ module Linearly
4
+ module Errors
5
+ describe BrokenContract do
6
+ let(:failures) do
7
+ {
8
+ missing: Validation::Failure::Missing.instance,
9
+ unexpected: Validation::Failure::Unexpected.instance,
10
+ }
11
+ end
12
+ let(:error) { described_class.new(failures) }
13
+
14
+ describe BrokenContract::Inputs do
15
+ let(:message) { 'failed input expectations: [missing, unexpected]' }
16
+
17
+ it { expect(error.message).to eq message }
18
+ it { expect(error.failures).to eq failures }
19
+ end # describe BrokenContract::Inputs
20
+
21
+ describe BrokenContract::Outputs do
22
+ let(:message) { 'failed output expectations: [missing, unexpected]' }
23
+
24
+ it { expect(error.message).to eq message }
25
+ it { expect(error.failures).to eq failures }
26
+ end # describe BrokenContract::Outputs
27
+ end # describe BrokenContract
28
+ end # module Errors
29
+ end # module Linearly
@@ -0,0 +1,13 @@
1
+ require 'spec_helper'
2
+
3
+ module Linearly
4
+ module Errors
5
+ describe StateNotReturned do
6
+ let(:value) { 'surprise!' }
7
+ let(:error) { described_class.new(value) }
8
+
9
+ it { expect(error.message).to eq 'String is not a Statefully::State' }
10
+ it { expect(error.value).to eq value }
11
+ end # describe StateNotReturned
12
+ end # module Errors
13
+ end # module Linearly
data/spec/flow_spec.rb ADDED
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+
3
+ module Linearly
4
+ describe Flow do
5
+ let(:step1_proc) { ->(state) { state.succeed(new_key: :new_val) } }
6
+ let(:step1) do
7
+ TestStep.new(
8
+ { key: String },
9
+ { new_key: Symbol },
10
+ step1_proc,
11
+ )
12
+ end
13
+ let(:step2) do
14
+ TestStep.new(
15
+ { other: Numeric },
16
+ {},
17
+ ->(state) { state.succeed },
18
+ )
19
+ end
20
+ let(:flow) { described_class.new(step1, step2) }
21
+
22
+ describe '#inputs' do
23
+ let(:inputs) { flow.inputs }
24
+
25
+ it { expect(inputs.length).to eq 2 }
26
+ it { expect(inputs.fetch(:key)).to eq String }
27
+ it { expect(inputs.fetch(:other)).to eq Numeric }
28
+ end # describe '#inputs'
29
+
30
+ describe '#outputs' do
31
+ let(:outputs) { flow.outputs }
32
+
33
+ it { expect(outputs.length).to eq 1 }
34
+ it { expect(outputs.fetch(:new_key)).to eq Symbol }
35
+ end # describe '#outputs'
36
+
37
+ describe '#>>' do
38
+ it { expect(flow.>>(step1)).to be_a Flow }
39
+ end # describe '#>>'
40
+
41
+ describe '#call' do
42
+ let(:state) { Statefully::State.create(**args) }
43
+ let(:result) { flow.call(state) }
44
+
45
+ context 'with correct input' do
46
+ let(:args) { { key: 'val', other: 7 } }
47
+
48
+ it { expect(result).to be_successful }
49
+ it { expect(result.history.length).to eq 3 }
50
+
51
+ context 'with a throwing step' do
52
+ let(:step1_proc) { ->(_) { raise 'Boom!' } }
53
+
54
+ it { expect(result).not_to be_successful }
55
+ it { expect(result.error).to be_a RuntimeError }
56
+ end # context 'with a throwing step'
57
+
58
+ context 'with a step not returning State' do
59
+ let(:step1_proc) { ->(_) { 'surprise!' } }
60
+
61
+ it { expect(result).not_to be_successful }
62
+ it { expect(result.error).to be_a Errors::StateNotReturned }
63
+ end # context 'with a step not returning State'
64
+ end # context 'with correct input'
65
+
66
+ context 'with missing initial state' do
67
+ let(:args) { { key: 'val' } }
68
+
69
+ it { expect(result).to be_failed }
70
+ it { expect(result.error).to be_a Errors::BrokenContract::Inputs }
71
+ it { expect(result.history.length).to eq 2 }
72
+ end # context 'with missing initial state'
73
+ end # describe '#call'
74
+ end # describe Flow
75
+ end # module Linearly
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+
3
+ module Linearly
4
+ describe Runner do
5
+ let(:state) { Statefully::State.create(key: 'val') }
6
+ let(:behavior) { ->(state) { state.succeed(new_key: 'new_val') } }
7
+ let(:inputs) { { key: true } }
8
+ let(:outputs) { { new_key: true } }
9
+ let(:step) { TestStep.new(inputs, outputs, behavior) }
10
+ let(:result) { described_class.new(step).call(state) }
11
+
12
+ context 'when all goes well' do
13
+ it { expect(result).to be_successful }
14
+ it { expect(result).not_to be_finished }
15
+ it { expect(result.key).to eq 'val' }
16
+ end # context 'when all goes well'
17
+
18
+ shared_examples 'returns_error' do |error_class|
19
+ it { expect(result).not_to be_successful }
20
+ it { expect(result.error).to be_a error_class }
21
+ end # shared_examples 'returns_error'
22
+
23
+ context 'when input validation fails' do
24
+ let(:inputs) { { other: true } }
25
+
26
+ it_behaves_like 'returns_error', Errors::BrokenContract::Inputs
27
+ end # context 'when input validation fails'
28
+
29
+ context 'when step fails' do
30
+ let(:behavior) { ->(state) { state.fail(RuntimeError.new('Boom!')) } }
31
+
32
+ it_behaves_like 'returns_error', RuntimeError
33
+ end # context 'when step fails'
34
+
35
+ context 'when step finishes' do
36
+ let(:behavior) { ->(state) { state.finish } }
37
+
38
+ it { expect(result).to be_finished }
39
+ end # context 'when step finishes'
40
+
41
+ context 'when output validation fails' do
42
+ let(:outputs) { { other: true } }
43
+
44
+ it_behaves_like 'returns_error', Errors::BrokenContract::Outputs
45
+ end # context 'when output validation fails'
46
+ end # describe Runner
47
+ end # module Linearly
@@ -0,0 +1,18 @@
1
+ require 'bundler/setup'
2
+ Bundler.setup
3
+
4
+ require 'pry'
5
+
6
+ if ENV['CI'] == 'true'
7
+ require 'simplecov'
8
+ SimpleCov.start { add_filter '/spec/' }
9
+
10
+ require 'codecov'
11
+ SimpleCov.formatter = SimpleCov::Formatter::Codecov
12
+ end
13
+
14
+ require 'linearly'
15
+
16
+ require 'dynamic_step'
17
+ require 'static_step'
18
+ require 'test_step'
@@ -0,0 +1,15 @@
1
+ module Linearly
2
+ class StaticStep < Step::Static
3
+ def self.inputs
4
+ { number: Integer }
5
+ end
6
+
7
+ def self.outputs
8
+ { string: String }
9
+ end
10
+
11
+ def call
12
+ succeed(string: (number + 1).to_s)
13
+ end
14
+ end # class StaticStep
15
+ end # module Linearly
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ module Linearly
4
+ module Step
5
+ describe Dynamic do
6
+ let(:step) { DynamicStep.new }
7
+
8
+ describe '#inputs' do
9
+ it { expect { step.inputs }.to raise_error NotImplementedError }
10
+ end # describe '#inputs'
11
+
12
+ describe '#outputs' do
13
+ it { expect { step.outputs }.to raise_error NotImplementedError }
14
+ end # describe '#outputs'
15
+
16
+ describe '#call' do
17
+ let(:state) { Statefully::State.create }
18
+
19
+ it { expect { step.call(state) }.to raise_error NotImplementedError }
20
+ end # describe '#call'
21
+
22
+ describe '#>>' do
23
+ let(:step) { DynamicStep::Valid.new }
24
+
25
+ it { expect(step.>>(step)).to be_a Flow }
26
+ end # describe '#>>'
27
+ end # describe Dynamic
28
+ end # module Step
29
+ end # module Linearly
@@ -0,0 +1,52 @@
1
+ require 'spec_helper'
2
+
3
+ module Linearly
4
+ describe Step::Static do
5
+ let(:args) { {} }
6
+ let(:state) { Statefully::State.create(**args) }
7
+
8
+ it { expect { described_class.inputs }.to raise_error NotImplementedError }
9
+ it { expect { described_class.outputs }.to raise_error NotImplementedError }
10
+
11
+ describe '.call' do
12
+ let(:result) { described_class.call(state) }
13
+
14
+ it { expect { result }.to raise_error NotImplementedError }
15
+ end # describe '.call'
16
+
17
+ describe 'implementation' do
18
+ subject { StaticStep }
19
+
20
+ describe '.>>' do
21
+ let(:flow) { subject.>>(subject) }
22
+
23
+ it { expect(flow).to be_a Flow }
24
+ end # describe '.>>'
25
+
26
+ describe '.call' do
27
+ let(:result) { subject.call(state) }
28
+
29
+ context 'with missing input' do
30
+ let(:args) { {} }
31
+
32
+ it { expect(result).not_to be_successful }
33
+ it { expect(result.error).to be_a NoMethodError }
34
+ end # context 'with missing input'
35
+
36
+ context 'with correct input' do
37
+ let(:args) { { number: 7 } }
38
+
39
+ it { expect(result).to be_successful }
40
+ it { expect(result.string).to eq '8' }
41
+ end # context 'with correct input'
42
+
43
+ context 'with incorrect input' do
44
+ let(:args) { { number: '7' } }
45
+
46
+ it { expect(result).not_to be_successful }
47
+ it { expect(result.error).to be_a TypeError }
48
+ end # context 'with incorrect input'
49
+ end # describe '.call'
50
+ end # describe 'implementation'
51
+ end # describe Step::Static
52
+ end # module Linearly
data/spec/test_step.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'forwardable'
2
+
3
+ module Linearly
4
+ class TestStep
5
+ extend Forwardable
6
+
7
+ attr_reader :inputs, :outputs
8
+ def_delegators :@behavior, :call
9
+
10
+ def initialize(inputs, outputs, behavior)
11
+ @inputs = inputs
12
+ @outputs = outputs
13
+ @behavior = behavior
14
+ end
15
+ end # class TestStep
16
+ end # module Linearly
@@ -0,0 +1,107 @@
1
+ require 'spec_helper'
2
+
3
+ module Linearly
4
+ describe Validation do
5
+ let(:validation) { described_class.new(expectations).call(state) }
6
+ let(:state) { Statefully::State.create(key: 'val') }
7
+
8
+ shared_examples 'validation_fails' do |error_class|
9
+ it { expect(validation).to be_failed }
10
+ it { expect(validation.error).to be_a error_class }
11
+ end # shared_examples 'validation_fails'
12
+
13
+ shared_examples 'fails_when_missing_field' do |error_class|
14
+ let(:field) { :other }
15
+
16
+ it_behaves_like 'validation_fails', error_class
17
+
18
+ it { expect(validation.error).to be_a error_class }
19
+
20
+ context 'with failures' do
21
+ let(:failures) { validation.error.failures }
22
+
23
+ it { expect(validation.error.failures).to have_key(field) }
24
+ it { expect(validation.error.failures.fetch(field)).to be_missing }
25
+ end # context 'with failures'
26
+ end # shared_examples 'fails_when_missing_field'
27
+
28
+ shared_examples 'fails_with_unexpected_value' do |error_class|
29
+ it_behaves_like 'validation_fails', error_class
30
+
31
+ it { expect(validation.error).to be_a error_class }
32
+
33
+ context 'with failures' do
34
+ let(:failures) { validation.error.failures }
35
+
36
+ it { expect(validation.error.failures).to have_key(field) }
37
+ it { expect(validation.error.failures.fetch(field)).not_to be_missing }
38
+ end # context 'with failures'
39
+ end # shared_examples 'fails_with_unexpected_value'
40
+
41
+ shared_examples 'succeeds_and_returns_state' do
42
+ it { expect(validation).to be_successful }
43
+ it { expect(validation).to eq state }
44
+ end # shared_examples 'succeeds_and_returns_state'
45
+
46
+ shared_examples 'supports_presence_expectation' do |error_class|
47
+ let(:field) { :key }
48
+ let(:expectations) { { field => true } }
49
+
50
+ it_behaves_like 'fails_when_missing_field', error_class
51
+ it_behaves_like 'succeeds_and_returns_state'
52
+ end # shared_examples 'supports_presence_expectation'
53
+
54
+ shared_examples 'supports_class_expectation' do |error_class|
55
+ let(:field) { :key }
56
+ let(:klass) { String }
57
+ let(:expectations) { { field => klass } }
58
+
59
+ it_behaves_like 'fails_when_missing_field', error_class
60
+
61
+ context 'when expectation is met (default)' do
62
+ it_behaves_like 'succeeds_and_returns_state'
63
+ end # context 'when expectation is met (default)'
64
+
65
+ context 'when expectation is not met' do
66
+ let(:klass) { Numeric }
67
+
68
+ it_behaves_like 'fails_with_unexpected_value', error_class
69
+ end # context 'when expectation is not met'
70
+ end # shared_examples 'supports_class_expectation'
71
+
72
+ shared_examples 'supports_proc_expectation' do |error_class|
73
+ let(:field) { :key }
74
+ let(:klass) { String }
75
+ let(:expectation) { ->(val) { val.length == 3 } }
76
+ let(:expectations) { { field => expectation } }
77
+
78
+ it_behaves_like 'fails_when_missing_field', error_class
79
+
80
+ context 'when expectation is met (default)' do
81
+ it_behaves_like 'succeeds_and_returns_state'
82
+ end # context 'when expectation is met (default)'
83
+
84
+ context 'when expectation is not met' do
85
+ let(:expectation) { ->(val) { val.length == 4 } }
86
+
87
+ it_behaves_like 'fails_with_unexpected_value', error_class
88
+ end # context 'when expectation is not met'
89
+ end # shared_examples 'supports_proc_expectation'
90
+
91
+ describe Validation::Inputs do
92
+ error = Errors::BrokenContract::Inputs
93
+
94
+ it_behaves_like 'supports_presence_expectation', error
95
+ it_behaves_like 'supports_class_expectation', error
96
+ it_behaves_like 'supports_proc_expectation', error
97
+ end # describe Validation::Inputs
98
+
99
+ describe Validation::Outputs do
100
+ error = Errors::BrokenContract::Outputs
101
+
102
+ it_behaves_like 'supports_presence_expectation', error
103
+ it_behaves_like 'supports_class_expectation', error
104
+ it_behaves_like 'supports_proc_expectation', error
105
+ end # describe Validation::Outputs
106
+ end # describe Validation
107
+ end # module Linearly