interaktor 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new(:rubocop)
7
+
8
+ task default: [:spec, :rubocop]
@@ -0,0 +1,19 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "interaktor"
3
+ spec.version = "0.1.3"
4
+
5
+ spec.author = "Taylor Thurlow"
6
+ spec.email = "taylorthurlow@me.com"
7
+ spec.description = "A common interface for building service objects."
8
+ spec.summary = "Simple service object implementation"
9
+ spec.homepage = "https://github.com/taylorthurlow/interaktor"
10
+ spec.license = "MIT"
11
+ spec.files = `git ls-files`.split
12
+ spec.test_files = spec.files.grep(/^spec/)
13
+ spec.required_ruby_version = ">= 2.5"
14
+ spec.require_path = "lib"
15
+
16
+ spec.add_runtime_dependency "zeitwerk", "~> 2.3.1"
17
+
18
+ spec.add_development_dependency "rake", "~> 13.0"
19
+ end
@@ -0,0 +1,209 @@
1
+ require "zeitwerk"
2
+
3
+ loader = Zeitwerk::Loader.for_gem
4
+ loader.push_dir(File.expand_path("../lib", __dir__))
5
+ loader.setup
6
+
7
+ module Interaktor
8
+ # When the Interaktor module is included in a class, add the relevant class
9
+ # methods and hooks to that class.
10
+ #
11
+ # @param base [Class] the class which is including the Interaktor module
12
+ def self.included(base)
13
+ base.class_eval do
14
+ extend ClassMethods
15
+ include Hooks
16
+ end
17
+
18
+ # @return [Interaktor::Context] this should not be used publicly
19
+ attr_accessor :context
20
+ end
21
+
22
+ module ClassMethods
23
+ # The list of attributes which are required to be passed in when calling
24
+ # the interaktor.
25
+ #
26
+ # @return [Array<Symbol>]
27
+ def required_attributes
28
+ @required_attributes ||= []
29
+ end
30
+
31
+ # The list of attributes which are NOT required to be passed in when
32
+ # calling the interaktor.
33
+ #
34
+ # @return [Array<Symbol>]
35
+ def optional_attributes
36
+ @optional_attributes ||= []
37
+ end
38
+
39
+ # The list of attributes which are required to be passed in when calling
40
+ # `#fail!` from within the interaktor.
41
+ #
42
+ # @return [Array<Symbol>]
43
+ def failure_attributes
44
+ @failure_attributes ||= []
45
+ end
46
+
47
+ # A DSL method for documenting required interaktor attributes.
48
+ #
49
+ # @param attributes [Symbol, Array<Symbol>] the list of attribute names
50
+ #
51
+ # @return [void]
52
+ def required(*attributes)
53
+ required_attributes.concat attributes
54
+
55
+ attributes.each do |attribute|
56
+ define_method(attribute) { context.send(attribute) }
57
+ define_method("#{attribute}=".to_sym) do |value|
58
+ context.send("#{attribute}=".to_sym, value)
59
+ end
60
+ end
61
+ end
62
+
63
+ # A DSL method for documenting optional interaktor attributes.
64
+ #
65
+ # @param attributes [Symbol, Array<Symbol>] the list of attribute names
66
+ #
67
+ # @return [void]
68
+ def optional(*attributes)
69
+ optional_attributes.concat attributes
70
+
71
+ attributes.each do |attribute|
72
+ define_method(attribute) { context.send(attribute) }
73
+ define_method("#{attribute}=".to_sym) do |value|
74
+ unless context.to_h.key?(attribute)
75
+ raise <<~ERROR
76
+ You can't assign a value to an optional parameter if you didn't
77
+ initialize the interaktor with it in the first place.
78
+ ERROR
79
+ end
80
+
81
+ context.send("#{attribute}=".to_sym, value)
82
+ end
83
+ end
84
+ end
85
+
86
+ # A DSL method for documenting required interaktor failure attributes.
87
+ #
88
+ # @param attributes [Symbol, Array<Symbol>] the list of attribute names
89
+ #
90
+ # @return [void]
91
+ def failure(*attributes)
92
+ failure_attributes.concat attributes
93
+ end
94
+
95
+ # Invoke an Interaktor. This is the primary public API method to an
96
+ # interaktor.
97
+ #
98
+ # @param context [Hash, Interaktor::Context] the context object as a hash
99
+ # with attributes or an already-built context
100
+ #
101
+ # @return [Interaktor::Context] the context, following interaktor execution
102
+ def call(context = {})
103
+ verify_attribute_presence(context)
104
+
105
+ new(context).tap(&:run).context
106
+ end
107
+
108
+ # Invoke an Interaktor. This method behaves identically to `#call`, with
109
+ # one notable exception - if the context is failed during the invocation of
110
+ # the interaktor, `Interaktor::Failure` is raised.
111
+ #
112
+ # @param context [Hash, Interaktor::Context] the context object as a hash
113
+ # with attributes or an already-built context
114
+ #
115
+ # @raises [Interaktor::Failure]
116
+ #
117
+ # @return [Interaktor::Context] the context, following interaktor execution
118
+ def call!(context = {})
119
+ verify_attribute_presence(context)
120
+
121
+ new(context).tap(&:run!).context
122
+ end
123
+
124
+ private
125
+
126
+ # Check the provided context against the attributes defined with the DSL
127
+ # methods, and determine if there are any attributes which are required and
128
+ # have not been provided.
129
+ #
130
+ # @param context [Interaktor::Context] the context to check
131
+ #
132
+ # @return [void]
133
+ def verify_attribute_presence(context)
134
+ # TODO: Add "allow_nil?" option to required attributes
135
+ missing_attrs = required_attributes.reject { |required_attr| context.to_h.key?(required_attr) }
136
+
137
+ raise <<~ERROR if missing_attrs.any?
138
+ Required attribute(s) were not provided when initializing #{name} interaktor:
139
+ #{missing_attrs.join("\n ")}
140
+ ERROR
141
+ end
142
+ end
143
+
144
+ # @param context [Hash, Interaktor::Context] the context object as a hash
145
+ # with attributes or an already-built context
146
+ def initialize(context = {})
147
+ @context = Interaktor::Context.build(context)
148
+ end
149
+
150
+ # Fail the current interaktor.
151
+ #
152
+ # @param failure_attributes [Hash{Symbol=>Object}] the context attributes
153
+ #
154
+ # @return [void]
155
+ def fail!(failure_attributes = {})
156
+ # Make sure we have all required attributes
157
+ missing_attrs = self.class.failure_attributes
158
+ .reject { |failure_attr| failure_attributes.key?(failure_attr) }
159
+ raise "Missing failure attrs: #{missing_attrs.join(", ")}" if missing_attrs.any?
160
+
161
+ context.fail!(failure_attributes)
162
+ end
163
+
164
+ # Invoke an Interaktor instance without any hooks, tracking, or rollback. It
165
+ # is expected that the `#call` instance method is overwritten for each
166
+ # interaktor class.
167
+ #
168
+ # @return [void]
169
+ def call; end
170
+
171
+ # Reverse prior invocation of an Interaktor instance. Any interaktor class
172
+ # that requires undoing upon downstream failure is expected to overwrite the
173
+ # `#rollback` instance method.
174
+ #
175
+ # @return [void]
176
+ def rollback; end
177
+
178
+ # Invoke an interaktor instance along with all defined hooks. The `run`
179
+ # method is used internally by the `call` class method. After successful
180
+ # invocation of the interaktor, the instance is tracked within the context.
181
+ # If the context is failed or any error is raised, the context is rolled
182
+ # back.
183
+ #
184
+ # @return [void]
185
+ def run
186
+ run!
187
+ rescue Interaktor::Failure # rubocop:disable Lint/SuppressedException
188
+ end
189
+
190
+ # Invoke an Interaktor instance along with all defined hooks, typically used
191
+ # internally by `.call!`. After successful invocation of the interaktor, the
192
+ # instance is tracked within the context. If the context is failed or any
193
+ # error is raised, the context is rolled back. This method behaves
194
+ # identically to `#run` with one notable exception - if the context is failed
195
+ # during the invocation of the interaktor, `Interaktor::Failure` is raised.
196
+ #
197
+ # @raises [Interaktor::Failure]
198
+ #
199
+ # @return [void]
200
+ def run!
201
+ with_hooks do
202
+ call
203
+ context.called!(self)
204
+ end
205
+ rescue StandardError
206
+ context.rollback!
207
+ raise
208
+ end
209
+ end
@@ -0,0 +1,91 @@
1
+ require "ostruct"
2
+
3
+ # The object for tracking state of an Interaktor's invocation. The context is
4
+ # used to initialize the interaktor with the information required for
5
+ # invocation. The interaktor manipulates the context to produce the result of
6
+ # invocation. The context is the mechanism by which success and failure are
7
+ # determined and the context is responsible for tracking individual interaktor
8
+ # invocations for the purpose of rollback. It may be manipulated using
9
+ # arbitrary getter and setter methods.
10
+ class Interaktor::Context < OpenStruct
11
+ # Initialize an Interaktor::Context or preserve an existing one. If the
12
+ # argument given is an Interaktor::Context, the argument is returned.
13
+ # Otherwise, a new Interaktor::Context is initialized from the provided hash.
14
+ # Used during interaktor initialization.
15
+ #
16
+ # @param context [Hash, Interaktor::Context] the context object as a hash
17
+ # with attributes or an already-built context
18
+ #
19
+ # @return [Interaktor::Context]
20
+ def self.build(context = {})
21
+ context.is_a?(Interaktor::Context) ? context : new(context)
22
+ end
23
+
24
+ # Whether the Interaktor::Context is successful. By default, a new context is
25
+ # successful and only changes when explicitly failed. This method is the
26
+ # inverse of the `#failure?` method.
27
+ #
28
+ # @return [Boolean] true by default, or false if failed
29
+ def success?
30
+ !failure?
31
+ end
32
+
33
+ # Whether the Interaktor::Context has failed. By default, a new context is
34
+ # successful and only changes when explicitly failed. This method is the
35
+ # inverse of the `#success?` method.
36
+ #
37
+ # @return [Boolean] false by default, or true if failed
38
+ def failure?
39
+ @failure || false
40
+ end
41
+
42
+ # Fail the Interaktor::Context. Failing a context raises an error that may be
43
+ # rescued by the calling interaktor. The context is also flagged as having
44
+ # failed. Optionally the caller may provide a hash of key/value pairs to be
45
+ # merged into the context before failure.
46
+ #
47
+ # @param context [Hash] data to be merged into the existing context
48
+ #
49
+ # @raises [Interaktor::Failure]
50
+ #
51
+ # @return [void]
52
+ def fail!(context = {})
53
+ context.each { |key, value| self[key.to_sym] = value }
54
+ @failure = true
55
+ raise Interaktor::Failure, self
56
+ end
57
+
58
+ # Roll back the Interaktor::Context. Any interaktors to which this context
59
+ # has been passed and which have been successfully called are asked to roll
60
+ # themselves back by invoking their `#rollback` methods.
61
+ #
62
+ # @return [Boolean] true if rolled back successfully, false if already
63
+ # rolled back
64
+ def rollback!
65
+ return false if @rolled_back
66
+
67
+ _called.reverse_each(&:rollback)
68
+ @rolled_back = true
69
+ end
70
+
71
+ # Track that an Interaktor has been called. The `#called!` method is used by
72
+ # the interaktor being invoked with this context. After an interaktor is
73
+ # successfully called, the interaktor instance is tracked in the context for
74
+ # the purpose of potential future rollback.
75
+ #
76
+ # @param interaktor [Interaktor] an interaktor that has been successfully
77
+ # called
78
+ #
79
+ # @return [void]
80
+ def called!(interaktor)
81
+ _called << interaktor
82
+ end
83
+
84
+ # An array of successfully called Interaktor instances invoked against this
85
+ # Interaktor::Context instance.
86
+ #
87
+ # @return [Array<Interaktor>]
88
+ def _called
89
+ @called ||= []
90
+ end
91
+ end
@@ -0,0 +1,13 @@
1
+ # Error raised during Interaktor::Context failure. The error stores a copy of
2
+ # the failed context for debugging purposes.
3
+ class Interaktor::Failure < StandardError
4
+ # @return [Interaktor::Context] the context of this failure instance
5
+ attr_reader :context
6
+
7
+ # @param context [Interaktor::Context] the context in which the error was
8
+ # raised
9
+ def initialize(context = nil)
10
+ @context = context
11
+ super
12
+ end
13
+ end
@@ -0,0 +1,264 @@
1
+ # Internal: Methods relating to supporting hooks around Interaktor invocation.
2
+ module Interaktor::Hooks
3
+ # Internal: Install Interaktor's behavior in the given class.
4
+ def self.included(base)
5
+ base.class_eval do
6
+ extend ClassMethods
7
+ end
8
+ end
9
+
10
+ # Internal: Interaktor::Hooks class methods.
11
+ module ClassMethods
12
+ # Public: Declare hooks to run around Interaktor invocation. The around
13
+ # method may be called multiple times; subsequent calls append declared
14
+ # hooks to existing around hooks.
15
+ #
16
+ # hooks - Zero or more Symbol method names representing instance methods
17
+ # to be called around interaktor invocation. Each instance method
18
+ # invocation receives an argument representing the next link in
19
+ # the around hook chain.
20
+ # block - An optional block to be executed as a hook. If given, the block
21
+ # is executed after methods corresponding to any given Symbols.
22
+ #
23
+ # Examples
24
+ #
25
+ # class MyInteraktor
26
+ # include Interaktor
27
+ #
28
+ # around :time_execution
29
+ #
30
+ # around do |interaktor|
31
+ # puts "started"
32
+ # interaktor.call
33
+ # puts "finished"
34
+ # end
35
+ #
36
+ # def call
37
+ # puts "called"
38
+ # end
39
+ #
40
+ # private
41
+ #
42
+ # def time_execution(interaktor)
43
+ # context.start_time = Time.now
44
+ # interaktor.call
45
+ # context.finish_time = Time.now
46
+ # end
47
+ # end
48
+ #
49
+ # Returns nothing.
50
+ def around(*hooks, &block)
51
+ hooks << block if block
52
+ hooks.each { |hook| around_hooks.push(hook) }
53
+ end
54
+
55
+ # Public: Declare hooks to run before Interaktor invocation. The before
56
+ # method may be called multiple times; subsequent calls append declared
57
+ # hooks to existing before hooks.
58
+ #
59
+ # hooks - Zero or more Symbol method names representing instance methods
60
+ # to be called before interaktor invocation.
61
+ # block - An optional block to be executed as a hook. If given, the block
62
+ # is executed after methods corresponding to any given Symbols.
63
+ #
64
+ # Examples
65
+ #
66
+ # class MyInteraktor
67
+ # include Interaktor
68
+ #
69
+ # before :set_start_time
70
+ #
71
+ # before do
72
+ # puts "started"
73
+ # end
74
+ #
75
+ # def call
76
+ # puts "called"
77
+ # end
78
+ #
79
+ # private
80
+ #
81
+ # def set_start_time
82
+ # context.start_time = Time.now
83
+ # end
84
+ # end
85
+ #
86
+ # Returns nothing.
87
+ def before(*hooks, &block)
88
+ hooks << block if block
89
+ hooks.each { |hook| before_hooks.push(hook) }
90
+ end
91
+
92
+ # Public: Declare hooks to run after Interaktor invocation. The after
93
+ # method may be called multiple times; subsequent calls prepend declared
94
+ # hooks to existing after hooks.
95
+ #
96
+ # hooks - Zero or more Symbol method names representing instance methods
97
+ # to be called after interaktor invocation.
98
+ # block - An optional block to be executed as a hook. If given, the block
99
+ # is executed before methods corresponding to any given Symbols.
100
+ #
101
+ # Examples
102
+ #
103
+ # class MyInteraktor
104
+ # include Interaktor
105
+ #
106
+ # after :set_finish_time
107
+ #
108
+ # after do
109
+ # puts "finished"
110
+ # end
111
+ #
112
+ # def call
113
+ # puts "called"
114
+ # end
115
+ #
116
+ # private
117
+ #
118
+ # def set_finish_time
119
+ # context.finish_time = Time.now
120
+ # end
121
+ # end
122
+ #
123
+ # Returns nothing.
124
+ def after(*hooks, &block)
125
+ hooks << block if block
126
+ hooks.each { |hook| after_hooks.unshift(hook) }
127
+ end
128
+
129
+ # Internal: An Array of declared hooks to run around Interaktor
130
+ # invocation. The hooks appear in the order in which they will be run.
131
+ #
132
+ # Examples
133
+ #
134
+ # class MyInteraktor
135
+ # include Interaktor
136
+ #
137
+ # around :time_execution, :use_transaction
138
+ # end
139
+ #
140
+ # MyInteraktor.around_hooks
141
+ # # => [:time_execution, :use_transaction]
142
+ #
143
+ # Returns an Array of Symbols and Procs.
144
+ def around_hooks
145
+ @around_hooks ||= []
146
+ end
147
+
148
+ # Internal: An Array of declared hooks to run before Interaktor
149
+ # invocation. The hooks appear in the order in which they will be run.
150
+ #
151
+ # Examples
152
+ #
153
+ # class MyInteraktor
154
+ # include Interaktor
155
+ #
156
+ # before :set_start_time, :say_hello
157
+ # end
158
+ #
159
+ # MyInteraktor.before_hooks
160
+ # # => [:set_start_time, :say_hello]
161
+ #
162
+ # Returns an Array of Symbols and Procs.
163
+ def before_hooks
164
+ @before_hooks ||= []
165
+ end
166
+
167
+ # Internal: An Array of declared hooks to run before Interaktor
168
+ # invocation. The hooks appear in the order in which they will be run.
169
+ #
170
+ # Examples
171
+ #
172
+ # class MyInteraktor
173
+ # include Interaktor
174
+ #
175
+ # after :set_finish_time, :say_goodbye
176
+ # end
177
+ #
178
+ # MyInteraktor.after_hooks
179
+ # # => [:say_goodbye, :set_finish_time]
180
+ #
181
+ # Returns an Array of Symbols and Procs.
182
+ def after_hooks
183
+ @after_hooks ||= []
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ # Internal: Run around, before and after hooks around yielded execution. The
190
+ # required block is surrounded with hooks and executed.
191
+ #
192
+ # Examples
193
+ #
194
+ # class MyProcessor
195
+ # include Interaktor::Hooks
196
+ #
197
+ # def process_with_hooks
198
+ # with_hooks do
199
+ # process
200
+ # end
201
+ # end
202
+ #
203
+ # def process
204
+ # puts "processed!"
205
+ # end
206
+ # end
207
+ #
208
+ # Returns nothing.
209
+ def with_hooks
210
+ run_around_hooks do
211
+ run_before_hooks
212
+ yield
213
+ run_after_hooks
214
+ end
215
+ end
216
+
217
+ # Internal: Run around hooks.
218
+ #
219
+ # Returns nothing.
220
+ def run_around_hooks(&block)
221
+ self.class.around_hooks.reverse.inject(block) { |chain, hook|
222
+ proc { run_hook(hook, chain) }
223
+ }.call
224
+ end
225
+
226
+ # Internal: Run before hooks.
227
+ #
228
+ # Returns nothing.
229
+ def run_before_hooks
230
+ run_hooks(self.class.before_hooks)
231
+ end
232
+
233
+ # Internal: Run after hooks.
234
+ #
235
+ # Returns nothing.
236
+ def run_after_hooks
237
+ run_hooks(self.class.after_hooks)
238
+ end
239
+
240
+ # Internal: Run a colection of hooks. The "run_hooks" method is the common
241
+ # interface by which collections of either before or after hooks are run.
242
+ #
243
+ # hooks - An Array of Symbol and Proc hooks.
244
+ #
245
+ # Returns nothing.
246
+ def run_hooks(hooks)
247
+ hooks.each { |hook| run_hook(hook) }
248
+ end
249
+
250
+ # Internal: Run an individual hook. The "run_hook" method is the common
251
+ # interface by which an individual hook is run. If the given hook is a
252
+ # symbol, the method is invoked whether public or private. If the hook is a
253
+ # proc, the proc is evaluated in the context of the current instance.
254
+ #
255
+ # hook - A Symbol or Proc hook.
256
+ # args - Zero or more arguments to be passed as block arguments into the
257
+ # given block or as arguments into the method described by the given
258
+ # Symbol method name.
259
+ #
260
+ # Returns nothing.
261
+ def run_hook(hook, *args)
262
+ hook.is_a?(Symbol) ? send(hook, *args) : instance_exec(*args, &hook)
263
+ end
264
+ end