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.
- 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
|