linearly 0.1.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.
@@ -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