interaktor 0.1.3

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,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