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.
@@ -0,0 +1,7 @@
1
+ module SmashTheState
2
+ end
3
+
4
+ require 'active_model'
5
+ require 'active_model_attributes'
6
+
7
+ require_relative 'smash_the_state/operation'
@@ -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