smash_the_state 1.2.4
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/README.md +422 -0
- data/Rakefile +21 -0
- data/lib/smash_the_state.rb +7 -0
- data/lib/smash_the_state/matchers.rb +56 -0
- data/lib/smash_the_state/operation.rb +111 -0
- data/lib/smash_the_state/operation/definition.rb +37 -0
- data/lib/smash_the_state/operation/dry_run.rb +75 -0
- data/lib/smash_the_state/operation/error.rb +19 -0
- data/lib/smash_the_state/operation/sequence.rb +116 -0
- data/lib/smash_the_state/operation/state.rb +104 -0
- data/lib/smash_the_state/operation/state_type.rb +19 -0
- data/lib/smash_the_state/operation/step.rb +21 -0
- data/lib/smash_the_state/version.rb +3 -0
- data/spec/unit/operation/definition_spec.rb +57 -0
- data/spec/unit/operation/sequence_spec.rb +256 -0
- data/spec/unit/operation/state_spec.rb +143 -0
- data/spec/unit/operation/step_spec.rb +30 -0
- data/spec/unit/operation_spec.rb +493 -0
- metadata +90 -0
@@ -0,0 +1,56 @@
|
|
1
|
+
module SmashTheState
|
2
|
+
module Matchers
|
3
|
+
RSpec::Matchers.define :continue_from do |original|
|
4
|
+
match do |continuation|
|
5
|
+
expect(
|
6
|
+
continuation.
|
7
|
+
sequence.
|
8
|
+
steps.
|
9
|
+
slice(0, original.sequence.steps.length)
|
10
|
+
).to eq(original.sequence.steps)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# expect(Some::Operation).to represent_with Thing::Representer
|
15
|
+
RSpec::Matchers.define :represent_with do |representer|
|
16
|
+
match do |operation|
|
17
|
+
expect(representer).to receive(:represent).and_return "representation"
|
18
|
+
expect(operation.call.as_json).to eq "representation"
|
19
|
+
end
|
20
|
+
|
21
|
+
# Magic: Calling this matcher with an operation works the way you'd expect. Calling it with
|
22
|
+
# a block turns the block into a proc and stuffs it into |operation|. Since operations and
|
23
|
+
# procs both respond to .call in the same way, `operation.call.as_json` does the same thing
|
24
|
+
# in both cases.
|
25
|
+
def supports_block_expectations?
|
26
|
+
true
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# expect(Some::Operation).to represent_collection_with Thing::Representer
|
31
|
+
RSpec::Matchers.define :represent_collection_with do |representer|
|
32
|
+
match do |operation|
|
33
|
+
expect(representer).to receive(:represent).and_return "representation"
|
34
|
+
expect(operation.call.as_json).to eq "representation"
|
35
|
+
end
|
36
|
+
|
37
|
+
def supports_block_expectations?
|
38
|
+
true
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# expect(Some::Operation).to represent_collection :things, with: Thing::Representer
|
43
|
+
RSpec::Matchers.define :represent_collection do |key, options|
|
44
|
+
match do |operation|
|
45
|
+
representer = options[:with]
|
46
|
+
|
47
|
+
expect(representer).to receive(:represent).and_return "representation"
|
48
|
+
expect(operation.call.as_json).to eq key.to_s => "representation"
|
49
|
+
end
|
50
|
+
|
51
|
+
def supports_block_expectations?
|
52
|
+
true
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require_relative 'operation/error'
|
2
|
+
require_relative 'operation/sequence'
|
3
|
+
require_relative 'operation/step'
|
4
|
+
require_relative 'operation/state'
|
5
|
+
require_relative 'operation/dry_run'
|
6
|
+
require_relative 'operation/state_type'
|
7
|
+
require_relative 'operation/definition'
|
8
|
+
|
9
|
+
module SmashTheState
|
10
|
+
class Operation
|
11
|
+
extend DryRun
|
12
|
+
|
13
|
+
class << self
|
14
|
+
attr_reader :state_class
|
15
|
+
|
16
|
+
# Runs the operation, creating the state based on the provided params,
|
17
|
+
# passing it from step to step and returning the last step.
|
18
|
+
def call(params = {})
|
19
|
+
run_sequence(sequence, params)
|
20
|
+
end
|
21
|
+
alias run call
|
22
|
+
|
23
|
+
# inheritance doesn't work with class attr_readers, this method is provided to
|
24
|
+
# bootstrap an operation as a continuation of a "prelude" operation
|
25
|
+
def continues_from(prelude)
|
26
|
+
@state_class = prelude.state_class && prelude.state_class.dup
|
27
|
+
sequence.steps.concat prelude.sequence.steps
|
28
|
+
|
29
|
+
# also make the dry run sequence continue
|
30
|
+
dry_run_sequence.steps.concat(prelude.dry_run_sequence.steps)
|
31
|
+
end
|
32
|
+
|
33
|
+
def schema(&block)
|
34
|
+
@state_class = Operation::State.build(&block)
|
35
|
+
end
|
36
|
+
|
37
|
+
def step(step_name, options = {}, &block)
|
38
|
+
sequence.add_step(step_name, options, &block)
|
39
|
+
end
|
40
|
+
|
41
|
+
def error(*steps, &block)
|
42
|
+
steps.each do |step_name|
|
43
|
+
sequence.add_error_handler_for_step(step_name, &block)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def policy(klass, method_name)
|
48
|
+
step :policy do |state, original_state|
|
49
|
+
state.tap do
|
50
|
+
policy_instance = klass.new(original_state.current_user, state)
|
51
|
+
|
52
|
+
# pass the policy instance back in the NotAuthorized exception so
|
53
|
+
# that the state, the user, and the policy can be inspected
|
54
|
+
policy_instance.send(method_name) ||
|
55
|
+
raise(NotAuthorized, policy_instance)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def middleware_class(&block)
|
61
|
+
sequence.middleware_class_block = block
|
62
|
+
end
|
63
|
+
|
64
|
+
def middleware_step(step_name, options = {})
|
65
|
+
sequence.add_middleware_step(step_name, options)
|
66
|
+
end
|
67
|
+
|
68
|
+
def validate(&block)
|
69
|
+
# when we add a validation step, all proceeding steps must not produce
|
70
|
+
# side-effects (subsequent steps are case-by-case)
|
71
|
+
sequence.mark_as_side_effect_free!
|
72
|
+
step :validate, side_effect_free: true do |state|
|
73
|
+
Operation::State.eval_validation_directives_block(state, &block)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def custom_validation(&block)
|
78
|
+
# when we add a validation step, all proceeding steps must not produce
|
79
|
+
# side-effects (subsequent steps are case-by-case)
|
80
|
+
sequence.mark_as_side_effect_free!
|
81
|
+
step :validate, side_effect_free: true do |state, original_state|
|
82
|
+
Operation::State.eval_custom_validator_block(state, original_state, &block)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def represent(representer)
|
87
|
+
step :represent, side_effect_free: true do |state|
|
88
|
+
representer.represent(state)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def sequence
|
93
|
+
@sequence ||= Operation::Sequence.new
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def error!(state)
|
99
|
+
raise Error, state
|
100
|
+
end
|
101
|
+
|
102
|
+
def run_sequence(sequence_to_run, params = {})
|
103
|
+
# state class can be nil if the schema is never defined. that's ok. in that
|
104
|
+
# situation it's up to the first step to produce the original state and we'll pass
|
105
|
+
# the params themselves in
|
106
|
+
state = state_class && state_class.new(params)
|
107
|
+
sequence_to_run.call(state || params)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module SmashTheState
|
2
|
+
class Operation
|
3
|
+
# fundamentally a definition is a re-usable schema block with a name
|
4
|
+
class Definition < SmashTheState::Operation::State
|
5
|
+
class << self
|
6
|
+
attr_reader :schema_block
|
7
|
+
|
8
|
+
# the "name" is available as a reference
|
9
|
+
def ref
|
10
|
+
@definition_name
|
11
|
+
end
|
12
|
+
|
13
|
+
# whenever this module is evaluated as a string, use its name
|
14
|
+
alias to_s ref
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
# assigns a name to the definition
|
19
|
+
def definition(definition_name)
|
20
|
+
@definition_name = definition_name
|
21
|
+
end
|
22
|
+
|
23
|
+
def schema(name = nil, options = {}, &block)
|
24
|
+
# if a name is provided, it's an inline schema or a reference to another
|
25
|
+
# definition
|
26
|
+
return super(name, options, &block) unless name.nil?
|
27
|
+
|
28
|
+
# called with no name, we infer that this is the definition's own schema. the
|
29
|
+
# provided schema block is both stored for re-use and also evaluated in the
|
30
|
+
# definition module itself
|
31
|
+
@schema_block = block
|
32
|
+
class_eval(&block)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module SmashTheState
|
2
|
+
class Operation
|
3
|
+
module DryRun
|
4
|
+
class Builder
|
5
|
+
attr_reader :wet_sequence, :dry_sequence
|
6
|
+
|
7
|
+
def initialize(wet_sequence)
|
8
|
+
@wet_sequence = wet_sequence
|
9
|
+
@dry_sequence = Operation::Sequence.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def step(step_name, &block)
|
13
|
+
referenced_steps = wet_sequence.steps_for_name(step_name)
|
14
|
+
|
15
|
+
if block
|
16
|
+
dry_sequence.add_step(step_name, side_effect_free: true, &block)
|
17
|
+
return
|
18
|
+
end
|
19
|
+
|
20
|
+
if referenced_steps.empty?
|
21
|
+
raise "dry run sequence referred to unknown step " \
|
22
|
+
"#{step_name.inspect}. make sure to define " \
|
23
|
+
"your dry run sequence last, after all your steps are defined"
|
24
|
+
end
|
25
|
+
|
26
|
+
referenced_steps.each do |referenced_step|
|
27
|
+
# we're gonna copy the implementation verbatim but add a new step marked as
|
28
|
+
# side-effect-free, because if the step was added to the dry run sequence it
|
29
|
+
# must be assumed to be side-effect-free
|
30
|
+
dry_sequence.add_step(
|
31
|
+
step_name,
|
32
|
+
side_effect_free: true,
|
33
|
+
&referenced_step.implementation
|
34
|
+
)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# dry runs are meant to produce the same types of output as a normal call/run,
|
40
|
+
# except they should not produce any side-effects (writing to a database, etc)
|
41
|
+
def dry_run(params = {})
|
42
|
+
# if an valid dry run sequence has been specified, use it. otherwise run the main
|
43
|
+
# sequence in "side-effect free mode" (filtering out steps that cause
|
44
|
+
# side-effects)
|
45
|
+
seq = if dry_run_sequence?
|
46
|
+
dry_run_sequence
|
47
|
+
else
|
48
|
+
sequence.side_effect_free
|
49
|
+
end
|
50
|
+
|
51
|
+
run_sequence(seq, params)
|
52
|
+
end
|
53
|
+
alias dry_call dry_run
|
54
|
+
|
55
|
+
def dry_run_sequence(&block)
|
56
|
+
# to keep the operation code cleaner, we will delegate dry run sequence building
|
57
|
+
# to another module (allows us to have a method named :step without having to make
|
58
|
+
# the operation :step method super complicated)
|
59
|
+
@dry_run_builder ||= DryRun::Builder.new(sequence)
|
60
|
+
|
61
|
+
# if a block is given, we want to evaluate it with the builder
|
62
|
+
@dry_run_builder.instance_eval(&block) if block_given?
|
63
|
+
|
64
|
+
# the builder will produce a side-effect-free sequence for us
|
65
|
+
@dry_run_builder.dry_sequence
|
66
|
+
end
|
67
|
+
|
68
|
+
# a valid dry run sequence should have at least one step. if there isn't at least
|
69
|
+
# one step, the dry run sequence is basically a no-op
|
70
|
+
def dry_run_sequence?
|
71
|
+
!dry_run_sequence.steps.empty?
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module SmashTheState
|
2
|
+
class Operation
|
3
|
+
class Error < StandardError
|
4
|
+
attr_reader :state
|
5
|
+
|
6
|
+
def initialize(state)
|
7
|
+
@state = state
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class NotAuthorized < StandardError
|
12
|
+
attr_reader :policy_instance
|
13
|
+
|
14
|
+
def initialize(policy_instance)
|
15
|
+
@policy_instance = policy_instance
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
module SmashTheState
|
2
|
+
class Operation
|
3
|
+
class Sequence
|
4
|
+
attr_accessor :middleware_class_block
|
5
|
+
attr_reader :steps, :run_options
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@steps = []
|
9
|
+
@run_options = { dry: false }
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(state)
|
13
|
+
run_steps(@steps, state)
|
14
|
+
end
|
15
|
+
|
16
|
+
def slice(start, count)
|
17
|
+
# slice should return a copy of the object being sliced
|
18
|
+
dup.tap do |seq|
|
19
|
+
# we're going to slice the steps, which is really the meat of a sequence, but we
|
20
|
+
# need to evaluate in the copy context so that we can swap out the steps for a
|
21
|
+
# new copy of steps (because note - even though we've copied the sequence
|
22
|
+
# already, the steps of the copy still refer to the steps of the original!)
|
23
|
+
seq.instance_eval do
|
24
|
+
@steps = seq.steps.slice(start, count)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# return a copy without the steps that produce side-effects
|
30
|
+
def side_effect_free
|
31
|
+
dup.tap do |seq|
|
32
|
+
seq.run_options[:dry] = true
|
33
|
+
seq.instance_eval do
|
34
|
+
@steps = seq.steps.select(&:side_effect_free?)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# marks all the the currently defined steps as free of side-effects
|
40
|
+
def mark_as_side_effect_free!
|
41
|
+
steps.each { |s| s.options[:side_effect_free] = true }
|
42
|
+
end
|
43
|
+
|
44
|
+
def add_step(step_name, options = {}, &block)
|
45
|
+
# mulitple validation steps are okay but otherwise step names need to be unique
|
46
|
+
if step_name != :validate && !steps_for_name(step_name).empty?
|
47
|
+
raise "an operation step named #{step_name.inspect} already exists"
|
48
|
+
end
|
49
|
+
|
50
|
+
@steps << Step.new(step_name, options, &block)
|
51
|
+
end
|
52
|
+
|
53
|
+
# returns steps named the specified name. it's generally bad form to have mulitple
|
54
|
+
# steps with the same name, but it can happen in some reasonable cases (the most
|
55
|
+
# common being :validate)
|
56
|
+
def steps_for_name(name)
|
57
|
+
steps.select { |s| s.name == name }
|
58
|
+
end
|
59
|
+
|
60
|
+
def add_error_handler_for_step(step_name, &block)
|
61
|
+
step = @steps.find { |s| s.name == step_name }
|
62
|
+
|
63
|
+
# should we raise an exception instead?
|
64
|
+
return if step.nil?
|
65
|
+
|
66
|
+
step.error_handler = block
|
67
|
+
end
|
68
|
+
|
69
|
+
# rubocop:disable Lint/ShadowedException
|
70
|
+
def middleware_class(state, original_state = nil)
|
71
|
+
middleware_class_block.call(state, original_state).constantize
|
72
|
+
rescue NameError, NoMethodError
|
73
|
+
nil
|
74
|
+
end
|
75
|
+
# rubocop:enable Lint/ShadowedException
|
76
|
+
|
77
|
+
def add_middleware_step(step_name, options = {})
|
78
|
+
step = Operation::Step.new step_name, options do |state, original_state|
|
79
|
+
if middleware_class(state, original_state).nil?
|
80
|
+
# no-op
|
81
|
+
state
|
82
|
+
else
|
83
|
+
middleware_class(state, original_state).send(step_name, state, original_state)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
@steps << step
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def run_steps(steps_to_run, state)
|
93
|
+
# retain a copy of the original state so that we can refer to it for posterity as
|
94
|
+
# the operation state gets mutated over time
|
95
|
+
original_state = state.dup
|
96
|
+
current_step = nil
|
97
|
+
|
98
|
+
steps_to_run.reduce(state) do |memo, step|
|
99
|
+
current_step = step
|
100
|
+
|
101
|
+
# we're gonna pass the state from the previous step into the implementation as
|
102
|
+
# 'memo', but for convenience, we'll also always pass the original state into
|
103
|
+
# the implementation as 'original_state' so that no matter what you can get to
|
104
|
+
# your original input
|
105
|
+
step.implementation.call(memo, original_state, run_options)
|
106
|
+
end
|
107
|
+
rescue Operation::State::Invalid => e
|
108
|
+
e.state
|
109
|
+
rescue Operation::Error => e
|
110
|
+
raise e if current_step.error_handler.nil?
|
111
|
+
|
112
|
+
current_step.error_handler.call(e.state, original_state)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require "active_support/core_ext/hash/indifferent_access"
|
2
|
+
|
3
|
+
module SmashTheState
|
4
|
+
class Operation
|
5
|
+
class State
|
6
|
+
include ActiveModel::Model
|
7
|
+
include ActiveModelAttributes
|
8
|
+
|
9
|
+
class Invalid < StandardError
|
10
|
+
attr_reader :state
|
11
|
+
|
12
|
+
def initialize(state)
|
13
|
+
@state = state
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class << self
|
18
|
+
attr_accessor :representer
|
19
|
+
|
20
|
+
def build(&block)
|
21
|
+
Class.new(self).tap do |k|
|
22
|
+
k.class_eval(&block)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# defines a nested schema inside of a state. can be nested arbitrarily
|
27
|
+
# deep. schemas may be described inline via a block *or* can be a reference to a
|
28
|
+
# definition
|
29
|
+
def schema(key, options = {}, &block)
|
30
|
+
attribute key,
|
31
|
+
:state_for_smashing,
|
32
|
+
options.merge(
|
33
|
+
# allow for schemas to be provided inline *or* as a reference to a
|
34
|
+
# type definition
|
35
|
+
schema: attribute_options_to_ref_block(options) || block
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
# for ActiveModel states we will treat the block as a collection of ActiveModel
|
40
|
+
# validator directives
|
41
|
+
def eval_validation_directives_block(state, &block)
|
42
|
+
state.tap do |s|
|
43
|
+
# each validate block should be a "fresh start" and not interfere with the
|
44
|
+
# previous blocks
|
45
|
+
s.class.clear_validators!
|
46
|
+
s.class_eval(&block)
|
47
|
+
s.validate || invalid!(s)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# for non-ActiveModel states we will just evaluate the block as a validator
|
52
|
+
def eval_custom_validator_block(state, original_state = nil)
|
53
|
+
yield(state, original_state)
|
54
|
+
invalid!(state) if state.errors.present?
|
55
|
+
state
|
56
|
+
end
|
57
|
+
|
58
|
+
def model_name
|
59
|
+
ActiveModel::Name.new(self, nil, "State")
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# if a reference to a definition is provided, use the reference schema block
|
65
|
+
def attribute_options_to_ref_block(options)
|
66
|
+
options[:ref] && options[:ref].schema_block
|
67
|
+
end
|
68
|
+
|
69
|
+
def invalid!(state)
|
70
|
+
raise Invalid, state
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
attr_accessor :current_user
|
75
|
+
|
76
|
+
def initialize(attributes = {})
|
77
|
+
@current_user = attributes.delete(:current_user) ||
|
78
|
+
attributes.delete("current_user")
|
79
|
+
|
80
|
+
indifferent_whitelisted_attributes = self.
|
81
|
+
class.
|
82
|
+
attributes_registry.
|
83
|
+
with_indifferent_access
|
84
|
+
|
85
|
+
# ActiveModel will raise an ActiveRecord::UnknownAttributeError if any unexpected
|
86
|
+
# attributes are passed in. since operations are meant to replace strong
|
87
|
+
# parameters and enforce against arbitrary mass assignment, we should filter the
|
88
|
+
# params to inclide only the whitelisted attributes.
|
89
|
+
# TODO: what about nested attributes?
|
90
|
+
whitelisted_attributes = attributes.select do |attribute|
|
91
|
+
indifferent_whitelisted_attributes.key? attribute
|
92
|
+
end
|
93
|
+
|
94
|
+
super(whitelisted_attributes)
|
95
|
+
end
|
96
|
+
|
97
|
+
def as_json
|
98
|
+
Hash[self.class.attributes_registry.keys.map do |key|
|
99
|
+
[key, send(key).as_json]
|
100
|
+
end]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|