smash_the_state 1.2.4

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