smash_the_state 1.2.4
Sign up to get free protection for your applications and to get access to all the features.
- 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
|