linearly 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/linearly/errors/broken_contract.rb +80 -0
- data/lib/linearly/errors/state_not_returned.rb +29 -0
- data/lib/linearly/flow.rb +144 -0
- data/lib/linearly/mixins/flow_builder.rb +22 -0
- data/lib/linearly/mixins/reducer.rb +32 -0
- data/lib/linearly/runner.rb +38 -0
- data/lib/linearly/step/dynamic.rb +50 -0
- data/lib/linearly/step/static.rb +120 -0
- data/lib/linearly/validation.rb +190 -0
- data/lib/linearly/version.rb +3 -0
- data/lib/linearly.rb +12 -0
- data/spec/dynamic_step.rb +15 -0
- data/spec/errors/broken_contract_spec.rb +29 -0
- data/spec/errors/state_not_returned_spec.rb +13 -0
- data/spec/flow_spec.rb +75 -0
- data/spec/runner_spec.rb +47 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/static_step.rb +15 -0
- data/spec/step/dynamic_spec.rb +29 -0
- data/spec/step/static_spec.rb +52 -0
- data/spec/test_step.rb +16 -0
- data/spec/validation_spec.rb +107 -0
- metadata +327 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: eac2749b4aad79a7f0a3720eb59024d1b1761747
|
4
|
+
data.tar.gz: 920e5ac7f405ac7326d439a7815c18ce353b3795
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f9e00e9d2d3064c3cf2934c080a77c43643e2c4375ce92bb335c45895502ab938b3021f0285a9470be171195db4e8a7a916cbb3aa48309a40f2e27b5573c5538
|
7
|
+
data.tar.gz: 622bde992eacf7a3c30bf2a0cfaae44fc0462e380b21038a4b56f24da010bb62e7b8d10033640cb1fd0b8d192b1d0783c2af18eb8b7e002f7d9c8c312f0fb06a
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
# rubocop:disable Metrics/LineLength
|
4
|
+
|
5
|
+
module Linearly
|
6
|
+
module Errors
|
7
|
+
# {BrokenContract} is what happens when inputs or outputs for a {Step} do
|
8
|
+
# not match expectations.
|
9
|
+
# @abstract
|
10
|
+
class BrokenContract < RuntimeError
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
# Input/output validation failures
|
14
|
+
#
|
15
|
+
# @return [Hash<Symbol, Validation::Failure>]
|
16
|
+
# @api public
|
17
|
+
# @example
|
18
|
+
# err = Linearly::Errors::Inputs.new(
|
19
|
+
# key: Linearly::Validation::Failure::Missing.instance,
|
20
|
+
# )
|
21
|
+
# err.failures
|
22
|
+
# => {:key => [missing]}
|
23
|
+
attr_reader :failures
|
24
|
+
|
25
|
+
# @!method keys
|
26
|
+
# @return [Array<Symbol>]
|
27
|
+
# @see https://docs.ruby-lang.org/en/2.0.0/Hash.html#method-i-keys Hash#keys
|
28
|
+
# @api public
|
29
|
+
# @example
|
30
|
+
# err = Linearly::Errors::BrokenContract::Inputs.new(
|
31
|
+
# key: Linearly::Validation::Failure::Missing.instance,
|
32
|
+
# )
|
33
|
+
# err.keys
|
34
|
+
# => [:key]
|
35
|
+
def_delegators :failures, :keys
|
36
|
+
|
37
|
+
# Constructor for a {BrokenContract} error
|
38
|
+
#
|
39
|
+
# @param failures [Hash<Symbol, Validation::Failure>]
|
40
|
+
#
|
41
|
+
# @api public
|
42
|
+
# @example
|
43
|
+
# Linearly::Errors::BrokenContract::Inputs.new(
|
44
|
+
# key: Linearly::Validation::Failure::Missing.instance,
|
45
|
+
# )
|
46
|
+
# => #<Linearly::Errors::BrokenContract::Inputs:
|
47
|
+
# failed input expectations: [key]>
|
48
|
+
def initialize(failures)
|
49
|
+
@failures = failures
|
50
|
+
super("#{copy}: [#{keys.join(', ')}]")
|
51
|
+
end
|
52
|
+
|
53
|
+
# {Inputs} means a {BrokenContract} on inputs.
|
54
|
+
class Inputs < BrokenContract
|
55
|
+
private
|
56
|
+
|
57
|
+
# Copy for the error message
|
58
|
+
#
|
59
|
+
# @return [String]
|
60
|
+
# @api private
|
61
|
+
def copy
|
62
|
+
'failed input expectations'
|
63
|
+
end
|
64
|
+
end # class Inputs
|
65
|
+
|
66
|
+
# {Outputs} means a {BrokenContract} on outputs.
|
67
|
+
class Outputs < BrokenContract
|
68
|
+
private
|
69
|
+
|
70
|
+
# Copy for the error message
|
71
|
+
#
|
72
|
+
# @return [String]
|
73
|
+
# @api private
|
74
|
+
def copy
|
75
|
+
'failed output expectations'
|
76
|
+
end
|
77
|
+
end # class Outputs
|
78
|
+
end # class BrokenContract
|
79
|
+
end # module Errors
|
80
|
+
end # module Linearly
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Linearly
|
2
|
+
module Errors
|
3
|
+
# {StateNotReturned} is an error that is getting thrown when one of {Step}s
|
4
|
+
# in the {Flow} does not return an instance of +Statefully::State+.
|
5
|
+
# @api public
|
6
|
+
class StateNotReturned < RuntimeError
|
7
|
+
# Value that caused the error
|
8
|
+
#
|
9
|
+
# @return [Object]
|
10
|
+
# @api public
|
11
|
+
# @example
|
12
|
+
# Linearly::Errors::StateNotReturned.new('surprise').value
|
13
|
+
# => "surprise"
|
14
|
+
attr_reader :value
|
15
|
+
|
16
|
+
# Constructor for the {StateNotReturned} class
|
17
|
+
#
|
18
|
+
# @param value [Object]
|
19
|
+
#
|
20
|
+
# @api public
|
21
|
+
# @example
|
22
|
+
# Linearly::Errors::StateNotReturned.new('surprise')
|
23
|
+
def initialize(value)
|
24
|
+
super("#{value.class.name} is not a Statefully::State")
|
25
|
+
@value = value
|
26
|
+
end
|
27
|
+
end # class StateNotReturned
|
28
|
+
end # module Errors
|
29
|
+
end # module Linearly
|
@@ -0,0 +1,144 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Linearly
|
4
|
+
class Flow
|
5
|
+
extend Forwardable
|
6
|
+
include Mixins::Reducer
|
7
|
+
|
8
|
+
# @!method call(state)
|
9
|
+
# Validate the input state and run steps as long as it's a +Success+
|
10
|
+
#
|
11
|
+
# @param [Statefully::State] state
|
12
|
+
#
|
13
|
+
# @return [Statefully::State]
|
14
|
+
# @api public
|
15
|
+
# @example
|
16
|
+
# flow = Linearly::Flow.new(
|
17
|
+
# Users::Find,
|
18
|
+
# Users::AddRole.new(:admin),
|
19
|
+
# Users::Save,
|
20
|
+
# )
|
21
|
+
# flow.call(Statefully::State.create(user_id: params[:id]))
|
22
|
+
#
|
23
|
+
# @!method inputs
|
24
|
+
# Inputs required for the {Flow}
|
25
|
+
#
|
26
|
+
# @return [Hash<Symbol, TrueClass>]
|
27
|
+
# @api public
|
28
|
+
# @example
|
29
|
+
# Linearly::Flow.new(Users::Find).inputs
|
30
|
+
# => {user_id: true}
|
31
|
+
#
|
32
|
+
# @!method outputs
|
33
|
+
# Outputs provided by the {Flow}
|
34
|
+
#
|
35
|
+
# @return [Hash<Symbol, TrueClass>]
|
36
|
+
# @api public
|
37
|
+
# @example
|
38
|
+
# Linearly::Flow.new(Users::Find).outputs
|
39
|
+
# => {user: true}
|
40
|
+
def_delegators :@contract, :inputs, :outputs
|
41
|
+
|
42
|
+
# Constructor for the {Flow}
|
43
|
+
#
|
44
|
+
# @param steps [Array<Step>] array of things that implement the +Step+
|
45
|
+
# interface (+call+, +inputs+ and +outputs+ methods).
|
46
|
+
#
|
47
|
+
# @api public
|
48
|
+
# @example
|
49
|
+
# flow = Linearly::Flow.new(
|
50
|
+
# Users::Find,
|
51
|
+
# Users::AddRole.new(:admin),
|
52
|
+
# Users::Save,
|
53
|
+
# )
|
54
|
+
def initialize(*steps)
|
55
|
+
@steps = steps
|
56
|
+
@contract = Contract.new(steps)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Convenience method to join +Step+s into one {Flow}
|
60
|
+
#
|
61
|
+
# @param other [Step]
|
62
|
+
#
|
63
|
+
# @return [Flow]
|
64
|
+
# @api public
|
65
|
+
# @example
|
66
|
+
# flow =
|
67
|
+
# Users::Find
|
68
|
+
# .>> Users::Update
|
69
|
+
# .>> Users::Save
|
70
|
+
def >>(other)
|
71
|
+
Flow.new(other, *@steps)
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
# Steps to be ran by the {Flow}
|
77
|
+
#
|
78
|
+
# @return [Array<Step>]
|
79
|
+
# @api private
|
80
|
+
def steps
|
81
|
+
[
|
82
|
+
Validation::Inputs.new(inputs),
|
83
|
+
*@steps.map(&Runner.method(:new)),
|
84
|
+
Validation::Outputs.new(outputs),
|
85
|
+
]
|
86
|
+
end
|
87
|
+
|
88
|
+
# {Contract} is a companion for the {Flow}, providing it with logic for
|
89
|
+
# properly determining required +inputs+ and expected +outputs+.
|
90
|
+
class Contract
|
91
|
+
extend Forwardable
|
92
|
+
|
93
|
+
# Inputs required for the {Flow}
|
94
|
+
#
|
95
|
+
# @return [Hash<Symbol, TrueClass>]
|
96
|
+
# @api private
|
97
|
+
attr_reader :inputs
|
98
|
+
|
99
|
+
# Outputs provided by the {Flow}
|
100
|
+
#
|
101
|
+
# @return [Hash<Symbol, TrueClass>]
|
102
|
+
# @api private
|
103
|
+
attr_reader :outputs
|
104
|
+
|
105
|
+
# Constructor for the {Contract}
|
106
|
+
#
|
107
|
+
# @param steps [Array<Step>] array of things that implement the +Step+
|
108
|
+
# interface (+call+, +inputs+ and +outputs+ methods).
|
109
|
+
#
|
110
|
+
# @api private
|
111
|
+
def initialize(steps)
|
112
|
+
@steps = steps
|
113
|
+
@inputs = {}
|
114
|
+
@outputs = {}
|
115
|
+
build
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
# Figure out inputs required and outputs provided by the {Flow}
|
121
|
+
#
|
122
|
+
# @return [Array] irrelevant
|
123
|
+
# @api private
|
124
|
+
def build
|
125
|
+
@steps.each(&method(:process))
|
126
|
+
[@inputs, @outputs].map(&:freeze)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Process a single step
|
130
|
+
#
|
131
|
+
# @param step [Step]
|
132
|
+
#
|
133
|
+
# @return [Hash] irrelevant
|
134
|
+
# @api private
|
135
|
+
def process(step)
|
136
|
+
step.inputs.each do |key, val|
|
137
|
+
@inputs[key] = val unless @inputs.key?(key) || @outputs.key?(key)
|
138
|
+
end
|
139
|
+
@outputs.merge!(step.outputs)
|
140
|
+
end
|
141
|
+
end # class Contract
|
142
|
+
private_constant :Contract
|
143
|
+
end # class Flow
|
144
|
+
end # module Linearly
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Linearly
|
2
|
+
module Mixins
|
3
|
+
module FlowBuilder
|
4
|
+
# @!method self.>>(other)
|
5
|
+
# Convenience method to create a {Flow} from linked Steps
|
6
|
+
#
|
7
|
+
# @param other [Step] next step in the {Flow}
|
8
|
+
#
|
9
|
+
# @return [Flow]
|
10
|
+
# @api public
|
11
|
+
# @example
|
12
|
+
# flow =
|
13
|
+
# Users::Find
|
14
|
+
# .>> Users::Update
|
15
|
+
# .>> Users::Save
|
16
|
+
def >>(other)
|
17
|
+
Flow.new(self, other)
|
18
|
+
end
|
19
|
+
end # module FlowBuilder
|
20
|
+
end # module Mixins
|
21
|
+
private_constant :Mixins
|
22
|
+
end # module Linearly
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'statefully'
|
2
|
+
|
3
|
+
module Linearly
|
4
|
+
module Mixins
|
5
|
+
# {Reducer} is a mixin to include in all classes which need to run more than
|
6
|
+
# one step.
|
7
|
+
# @api private
|
8
|
+
module Reducer
|
9
|
+
# Keep calling steps as long as the state is successful
|
10
|
+
#
|
11
|
+
# This method reeks of :reek:TooManyStatements and :reek:FeatureEnvy.
|
12
|
+
#
|
13
|
+
# @param state [Statefully::State]
|
14
|
+
#
|
15
|
+
# @return [Statefully::State]
|
16
|
+
# @api private
|
17
|
+
def call(state)
|
18
|
+
steps.reduce(state) do |current, step|
|
19
|
+
break current if current.failed? || current.finished?
|
20
|
+
begin
|
21
|
+
next_state = step.call(current)
|
22
|
+
rescue StandardError => err
|
23
|
+
break current.fail(err)
|
24
|
+
end
|
25
|
+
next next_state if next_state.is_a?(Statefully::State)
|
26
|
+
current.fail(Errors::StateNotReturned.new(step))
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end # module Reducer
|
30
|
+
end # module Mixins
|
31
|
+
private_constant :Mixins
|
32
|
+
end # module Linearly
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Linearly
|
2
|
+
# {Runner} is a wrapper around a single step with inputs and outputs, which
|
3
|
+
# validates the inputs, runs the step, and validates the outputs.
|
4
|
+
# @api private
|
5
|
+
class Runner
|
6
|
+
include Mixins::Reducer
|
7
|
+
|
8
|
+
# Constructor for the {Runner} object
|
9
|
+
# @param step [Step] anything that implements the +Step+ interface
|
10
|
+
# (+call+, +inputs+ and +outputs+ methods).
|
11
|
+
#
|
12
|
+
# @api private
|
13
|
+
def initialize(step)
|
14
|
+
@step = step
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
# Return the wrapped {Step}
|
20
|
+
#
|
21
|
+
# @return [Step]
|
22
|
+
# @api private
|
23
|
+
attr_reader :step
|
24
|
+
|
25
|
+
# Wrap the provided {Step} with input and output validation
|
26
|
+
#
|
27
|
+
# @return [Array<Step>]
|
28
|
+
# @api private
|
29
|
+
def steps
|
30
|
+
[
|
31
|
+
Validation::Inputs.new(step.inputs),
|
32
|
+
step,
|
33
|
+
Validation::Outputs.new(step.outputs),
|
34
|
+
]
|
35
|
+
end
|
36
|
+
end # class Runner
|
37
|
+
private_constant :Runner
|
38
|
+
end # module Linearly
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Linearly
|
2
|
+
module Step
|
3
|
+
module Dynamic
|
4
|
+
include Mixins::FlowBuilder
|
5
|
+
|
6
|
+
# Inputs for a step
|
7
|
+
#
|
8
|
+
# An invalid implementation is provided to ensure that a failure to
|
9
|
+
# override this method is not quietly caught as a StandardError.
|
10
|
+
#
|
11
|
+
# @return [Hash<Symbol, Expectation>]
|
12
|
+
# @api public
|
13
|
+
# @example
|
14
|
+
# FindUser.new.inputs
|
15
|
+
# => { user_id: Integer }
|
16
|
+
def inputs
|
17
|
+
raise NotImplementedError
|
18
|
+
end
|
19
|
+
|
20
|
+
# Outputs for a step
|
21
|
+
#
|
22
|
+
# An invalid implementation is provided to ensure that a failure to
|
23
|
+
# override this method is not quietly caught as a StandardError.
|
24
|
+
#
|
25
|
+
# @return [Hash<Symbol, Expectation>]
|
26
|
+
# @api public
|
27
|
+
# @example
|
28
|
+
# FindUser.new.outputs
|
29
|
+
# => { user: User }
|
30
|
+
def outputs
|
31
|
+
raise NotImplementedError
|
32
|
+
end
|
33
|
+
|
34
|
+
# User-defined logic for this +Step+
|
35
|
+
#
|
36
|
+
# An invalid implementation is provided to ensure that a failure to
|
37
|
+
# override this method is not quietly caught as a StandardError.
|
38
|
+
#
|
39
|
+
# @param _state [Statefully::State]
|
40
|
+
#
|
41
|
+
# @return [Statefully::State]
|
42
|
+
# @api public
|
43
|
+
# @example
|
44
|
+
# FindUser.new.call(Statefully::State.create(user_id: 7))
|
45
|
+
def call(_state)
|
46
|
+
raise NotImplementedError
|
47
|
+
end
|
48
|
+
end # module Dynamic
|
49
|
+
end # module Step
|
50
|
+
end # module Linearly
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Linearly
|
2
|
+
module Step
|
3
|
+
# {Static} is a type of +Step+ whose operation solely depends on the content
|
4
|
+
# of the +State+ passed to its {.call} method. It's the best and most
|
5
|
+
# deterministic type of a +Step+, hence we provided a helper. Inheriting
|
6
|
+
# from {Static} will still require you to implement three methods: class
|
7
|
+
# +inputs+ and +outputs+ methods, and an instance +call+ method. What you
|
8
|
+
# get though is that your +call+ method does not need to take any parameters
|
9
|
+
# and will have access to the private +state+ instance variable. What's
|
10
|
+
# more, any unknown messages will be forwarded to +state+, so that your code
|
11
|
+
# can be shorter and more expressive. Also, you don't have to explicitly
|
12
|
+
# rescue exceptions - the static {.call} method will catch those and fail
|
13
|
+
# the +state+ accordingly.
|
14
|
+
class Static
|
15
|
+
extend Mixins::FlowBuilder
|
16
|
+
|
17
|
+
# Main entry point to {Step::Static}
|
18
|
+
#
|
19
|
+
# @param state [Statefully::State]
|
20
|
+
#
|
21
|
+
# @return [Statefully::State]
|
22
|
+
# @api public
|
23
|
+
# @example
|
24
|
+
# class FindUser < Linearly::Step::Static
|
25
|
+
# def self.inputs
|
26
|
+
# { user_id: Integer }
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# def self.outputs
|
30
|
+
# { user: User }
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# def call
|
34
|
+
# succeed(user: User.find(user_id))
|
35
|
+
# end
|
36
|
+
# end # class FindUser
|
37
|
+
# FindUser.call(Statefully::State.create(user_id: params[:id]))
|
38
|
+
def self.call(state)
|
39
|
+
new(state).call
|
40
|
+
rescue StandardError => err
|
41
|
+
state.fail(err)
|
42
|
+
end
|
43
|
+
|
44
|
+
# User-defined logic for this +Step+
|
45
|
+
#
|
46
|
+
# @return [Statefully::State]
|
47
|
+
# @api private
|
48
|
+
def call
|
49
|
+
raise NotImplementedError
|
50
|
+
end
|
51
|
+
|
52
|
+
# Inputs for a step
|
53
|
+
#
|
54
|
+
# @return [Hash<Symbol, Expectation>]
|
55
|
+
# @api public
|
56
|
+
# @example
|
57
|
+
# FindUser.inputs
|
58
|
+
# => { user_id: Integer }
|
59
|
+
def self.inputs
|
60
|
+
raise NotImplementedError
|
61
|
+
end
|
62
|
+
|
63
|
+
# Outputs for a step
|
64
|
+
#
|
65
|
+
# @return [Hash<Symbol, Expectation>]
|
66
|
+
# @api public
|
67
|
+
# @example
|
68
|
+
# FindUser.outputs
|
69
|
+
# => { user: User }
|
70
|
+
def self.outputs
|
71
|
+
raise NotImplementedError
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
# {State} received through the constructor
|
77
|
+
#
|
78
|
+
# @return [Statefully::State]
|
79
|
+
# @api private
|
80
|
+
attr_reader :state
|
81
|
+
|
82
|
+
# Constructor for the {Step::Static}
|
83
|
+
#
|
84
|
+
# @param state [Statefully::State]
|
85
|
+
# @api private
|
86
|
+
def initialize(state)
|
87
|
+
@state = state
|
88
|
+
end
|
89
|
+
private_class_method :new
|
90
|
+
|
91
|
+
# Dynamically pass unknown messages to the wrapped +State+
|
92
|
+
#
|
93
|
+
# @param name [Symbol|String]
|
94
|
+
# @param args [Array<Object>]
|
95
|
+
# @param block [Proc]
|
96
|
+
#
|
97
|
+
# @return [Object]
|
98
|
+
# @raise [NoMethodError]
|
99
|
+
# @api private
|
100
|
+
def method_missing(name, *args, &block)
|
101
|
+
state.send(name, *args, &block)
|
102
|
+
rescue NoMethodError
|
103
|
+
super
|
104
|
+
end
|
105
|
+
|
106
|
+
# Companion to `method_missing`
|
107
|
+
#
|
108
|
+
# This method reeks of :reek:BooleanParameter.
|
109
|
+
#
|
110
|
+
# @param name [Symbol|String]
|
111
|
+
# @param include_private [Boolean]
|
112
|
+
#
|
113
|
+
# @return [Boolean]
|
114
|
+
# @api private
|
115
|
+
def respond_to_missing?(name, include_private = false)
|
116
|
+
state.send(:respond_to_missing?, name, include_private)
|
117
|
+
end
|
118
|
+
end # class Static
|
119
|
+
end # module Step
|
120
|
+
end # module Linearly
|