activeinteractor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ require 'active_interactor/version'
6
+
7
+ # Copyright (c) 2019 Aaron Allen
8
+ #
9
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ # of this software and associated documentation files (the "Software"), to deal
11
+ # in the Software without restriction, including without limitation the rights
12
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ # copies of the Software, and to permit persons to whom the Software is
14
+ # furnished to do so, subject to the following conditions:
15
+ #
16
+ # The above copyright notice and this permission notice shall be included in
17
+ # all copies or substantial portions of the Software.
18
+ #
19
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25
+ # THE SOFTWARE.
26
+ #
27
+ # @author Aaron Allen <hello@aaronmallen.me>
28
+ # @since 0.0.1
29
+ # @version 0.1
30
+ module ActiveInteractor
31
+ extend ActiveSupport::Autoload
32
+
33
+ autoload :Base
34
+ autoload :Configuration
35
+ autoload :Context
36
+ autoload :Interactor
37
+
38
+ class << self
39
+ # The ActiveInteractor configuration
40
+ # @return [ActiveInteractor::Configuration] the configuration instance
41
+ def configuration
42
+ @configuration ||= Configuration.new
43
+ end
44
+
45
+ # Configures the ActiveInteractor gem
46
+ #
47
+ # @example Configure ActiveInteractor
48
+ # ActiveInteractor.configure do |config|
49
+ # config.logger = Rails.logger
50
+ # end
51
+ #
52
+ # @yield [ActiveInteractor#configuration]
53
+ def configure
54
+ yield(configuration)
55
+ end
56
+
57
+ # The ActiveInteractor logger object
58
+ # @return [Logger] the configured logger instance
59
+ def logger
60
+ configuration.logger
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteractor
4
+ # The Base Interactor class inherited by all interactors
5
+ #
6
+ # @author Aaron Allen <hello@aaronmallen.me>
7
+ # @since 0.0.1
8
+ # @version 0.1
9
+ class Base
10
+ include Interactor
11
+ # A new instance of {Base}
12
+ # @param context [Hash, nil] the properties of the context
13
+ # @return [ActiveInteractor::Base] a new instance of {Base}
14
+ def initialize(context = {})
15
+ @context = self.class.context_class.new(self, context)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteractor
4
+ # The Configuration object for the ActiveInteractor gem
5
+ #
6
+ # @author Aaron Allen <hello@aaronmallen.me>
7
+ # @since 0.0.1
8
+ # @version 0.1
9
+ #
10
+ # @!attribute [rw] logger
11
+ # @return [Logger] an instance of Logger
12
+ class Configuration
13
+ # The default configuration options for {Configuration}
14
+ # @return [Hash{Symbol => *}]
15
+ DEFAULTS = {
16
+ logger: Logger.new(STDOUT)
17
+ }.freeze
18
+
19
+ attr_accessor :logger
20
+
21
+ # A new instance of {Configuration}
22
+ # @param options [Hash{Symbol => *}] the options to initialize the
23
+ # configuration with.
24
+ # @option options [Logger] :logger the logger ActiveInteractor should
25
+ # use for logging
26
+ # @return [ActiveInteractor::Configuration] a new instance of {Configuration}
27
+ def initialize(options = {})
28
+ options = DEFAULTS.merge(options.dup || {}).slice(*DEFAULTS.keys)
29
+ options.each_key do |attribute|
30
+ instance_variable_set("@#{attribute}", options[attribute])
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/class/attribute'
4
+ require 'ostruct'
5
+
6
+ module ActiveInteractor
7
+ # ActiveInteractor::Context module
8
+ #
9
+ # @author Aaron Allen <hello@aaronmallen.me>
10
+ # @since 0.0.1
11
+ # @version 0.1
12
+ module Context
13
+ # Raised when an interactor context fails
14
+ #
15
+ # @author Aaron Allen <hello@aaronmallen.me>
16
+ # @since 0.0.1
17
+ # @version 0.1
18
+ #
19
+ # @!attribute [r] context
20
+ # @return [Base] an instance of {Base}
21
+ class Failure < StandardError
22
+ attr_reader :context
23
+
24
+ # A new instance of {Failure}
25
+ # @param context [Hash] an instance of {Base}
26
+ # @return [Failure] a new instance of {Failure}
27
+ def initialize(context = {})
28
+ @context = context
29
+ super
30
+ end
31
+ end
32
+
33
+ # The base context class inherited by all {Interactor::Context} classes
34
+ #
35
+ # @author Aaron Allen <hello@aaronmallen.me>
36
+ # @since 0.0.1
37
+ # @version 0.1
38
+ class Base < OpenStruct
39
+ include ActiveModel::Validations
40
+
41
+ class_attribute :__default_attributes, instance_writer: false, default: []
42
+
43
+ # A new instance of {Base}
44
+ # @param interactor [ActiveInteractor::Base] an interactor instance
45
+ # @param attributes [Hash, nil] the attributes of the context
46
+ # @return [ActiveInteractor::Context::Base] a new instance of {Base}
47
+ def initialize(interactor, attributes = {})
48
+ copy_flags!(attributes)
49
+ @interactor = interactor
50
+ super(attributes)
51
+ end
52
+
53
+ class << self
54
+ # Attributes defined on the context class
55
+ #
56
+ # @example Get attributes defined on a context class
57
+ # MyInteractor::Context.attributes
58
+ # #=> [:first_name, :last_name]
59
+ #
60
+ # @return [Array<Symbol>] the defined attributes
61
+ def attributes
62
+ __default_attributes
63
+ .concat(_validate_callbacks.map(&:filter).map(&:attributes).flatten)
64
+ .flatten
65
+ .uniq
66
+ end
67
+
68
+ # Set attributes on a context class
69
+ # @param [Array<Symbol, String>] attributes
70
+ #
71
+ # @example Define attributes on a context class
72
+ # MyInteractor::Context.attributes = :first_name, :last_name
73
+ # #=> [:first_name, :last_name]
74
+ #
75
+ # @return [Array<Symbol>] the defined attributes
76
+ def attributes=(*attributes)
77
+ self.__default_attributes = self.attributes.concat(attributes.flatten.map(&:to_sym)).uniq
78
+ end
79
+ end
80
+
81
+ # Attributes defined on the instance
82
+ #
83
+ # @example Get attributes defined on an instance
84
+ # MyInteractor::Context.attributes = :first_name, :last_name
85
+ # #=> [:first_name, :last_name]
86
+ #
87
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen')
88
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen'>
89
+ #
90
+ # context.attributes
91
+ # #=> { first_name: 'Aaron', last_name: 'Allen' }
92
+ #
93
+ # @example Get attributes defined on an instance with unknown attribute
94
+ # MyInteractor::Context.attributes = :first_name, :last_name
95
+ # #=> [:first_name, :last_name]
96
+ #
97
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen', unknown: 'unknown')
98
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen', unknown='unknown'>
99
+ #
100
+ # context.attributes
101
+ # #=> { first_name: 'Aaron', last_name: 'Allen' }
102
+ #
103
+ # context.unknown
104
+ # #=> 'unknown'
105
+ #
106
+ # @return [Hash{Symbol => *}] the defined attributes and values
107
+ def attributes
108
+ self.class.attributes.each_with_object({}) do |attribute, hash|
109
+ hash[attribute] = self[attribute] if self[attribute]
110
+ end
111
+ end
112
+
113
+ # Track that an Interactor has been called. The {#called!} method
114
+ # is used by the interactor being invoked with this context. After an
115
+ # interactor is successfully called, the interactor instance is tracked in
116
+ # the context for the purpose of potential future rollback
117
+ #
118
+ # @return [Array<ActiveInteractor::Base>] all called interactors
119
+ def called!
120
+ _called << interactor
121
+ end
122
+
123
+ # Removes properties from the instance that are not
124
+ # explicitly defined in the context instance {#attributes}
125
+ #
126
+ # @example Clean an instance of Context with unknown attribute
127
+ # MyInteractor::Context.attributes = :first_name, :last_name
128
+ # #=> [:first_name, :last_name]
129
+ #
130
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen', unknown: 'unknown')
131
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen', unknown='unknown'>
132
+ #
133
+ # context.unknown
134
+ # #=> 'unknown'
135
+ #
136
+ # context.clean!
137
+ # #=> { unknown: 'unknown' }
138
+ #
139
+ # context.unknown
140
+ # #=> nil
141
+ #
142
+ # @return [Hash{Symbol => *}] the deleted attributes
143
+ def clean!
144
+ deleted = {}
145
+ return deleted if keys.empty?
146
+
147
+ keys.reject { |key| self.class.attributes.include?(key) }.each do |attribute|
148
+ deleted[attribute] = self[attribute] if self[attribute]
149
+ delete_field(key.to_s)
150
+ end
151
+ deleted
152
+ end
153
+
154
+ # Fail the context instance. Failing a context raises an error
155
+ # that may be rescued by the calling interactor. The context is also flagged
156
+ # as having failed
157
+ #
158
+ # @example Fail an interactor context
159
+ # interactor = MyInteractor.new(name: 'Aaron')
160
+ # #=> <#MyInteractor name='Aaron'>
161
+ #
162
+ # interactor.context.fail!
163
+ # #=> ActiveInteractor::Context::Failure: <#MyInteractor::Context name='Aaron'>
164
+ #
165
+ # @param errors [ActiveModel::Errors, Hash] errors to add to the context on failure
166
+ # @see https://api.rubyonrails.org/classes/ActiveModel/Errors.html ActiveModel::Errors
167
+ # @raise [Failure]
168
+ def fail!(errors = {})
169
+ self.errors.merge!(errors) unless errors.empty?
170
+ @_failed = true
171
+ raise Failure, self
172
+ end
173
+
174
+ # Whether the context instance has failed. By default, a new
175
+ # context is successful and only changes when explicitly failed
176
+ #
177
+ # @note The {#failure?} method is the inverse of the {#success?} method
178
+ #
179
+ # @example Check if a context has failed
180
+ # context = MyInteractor::Context.new
181
+ # #=> <#MyInteractor::Context>
182
+ #
183
+ # context.failure?
184
+ # false
185
+ #
186
+ # context.fail!
187
+ # #=> ActiveInteractor::Context::Failure: <#MyInteractor::Context>
188
+ #
189
+ # context.failure?
190
+ # #=> true
191
+ #
192
+ # @return [Boolean] `false` by default or `true` if failed
193
+ def failure?
194
+ @_failed || false
195
+ end
196
+ alias fail? failure?
197
+
198
+ # All keys of properties currently defined on the instance
199
+ #
200
+ # @example An instance of Context with unknown attribute
201
+ # MyInteractor::Context.attributes = :first_name, :last_name
202
+ # #=> [:first_name, :last_name]
203
+ #
204
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen', unknown: 'unknown')
205
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen', unknown='unknown'>
206
+ #
207
+ # context.keys
208
+ # #=> [:first_name, :last_name, :unknown]
209
+ #
210
+ # @return [Array<Symbol>] keys defined on the instance
211
+ def keys
212
+ each_pair.map { |pair| pair[0].to_sym }
213
+ end
214
+
215
+ # Attempt to call the interactor for missing validation callback methods
216
+ # @raise [NameError] if the method is not a validation callback or method
217
+ # does not exist on the interactor instance
218
+ def method_missing(name, *args, &block)
219
+ interactor.send(name, *args, &block) if validation_callback?(name)
220
+ super
221
+ end
222
+
223
+ # Attempt to call the interactor for missing validation callback methods
224
+ # @return [Boolean] `true` if method is a validation callback and exists
225
+ # on the interactor instance
226
+ def respond_to_missing?(name, include_private)
227
+ return false unless validation_callback?(name)
228
+
229
+ interactor.respond_to?(name, include_private)
230
+ end
231
+
232
+ # Roll back an interactor context. Any interactors to which this
233
+ # context has been passed and which have been successfully called are asked
234
+ # to roll themselves back by invoking their
235
+ # {ActiveInteractor::Interactor#rollback #rollback} instance methods.
236
+ #
237
+ # @example Rollback an interactor's context
238
+ # context = MyInteractor.perform(name: 'Aaron')
239
+ # #=> <#MyInteractor::Context name='Aaron'>
240
+ #
241
+ # context.rollback!
242
+ # #=> true
243
+ #
244
+ # context
245
+ # #=> <#MyInteractor::Context name='Aaron'>
246
+ #
247
+ # @return [Boolean] `true` if rolled back successfully or `false` if already
248
+ # rolled back
249
+ def rollback!
250
+ return false if @_rolled_back
251
+
252
+ _called.reverse_each(&:execute_rollback)
253
+ @_rolled_back = true
254
+ end
255
+
256
+ # Whether the context instance is successful. By default, a new
257
+ # context is successful and only changes when explicitly failed
258
+ #
259
+ # @note the {#success?} method is the inverse of the {#failure?} method
260
+ #
261
+ # @example Check if a context has failed
262
+ # context = MyInteractor::Context.new
263
+ # #=> <#MyInteractor::Context>
264
+ #
265
+ # context.success?
266
+ # true
267
+ #
268
+ # context.fail!
269
+ # #=> ActiveInteractor::Context::Failure: <#MyInteractor::Context>
270
+ #
271
+ # context.success?
272
+ # #=> false
273
+ #
274
+ # @return [Boolean] `true` by default or `false` if failed
275
+ def success?
276
+ !failure?
277
+ end
278
+ alias successful? success?
279
+
280
+ private
281
+
282
+ attr_reader :interactor
283
+
284
+ def copy_flags!(context)
285
+ @_called = context.send(:_called) if context.respond_to?(:_called, true)
286
+ @_failed = context.failure? if context.respond_to?(:failure?)
287
+ end
288
+
289
+ def _called
290
+ @_called ||= []
291
+ end
292
+
293
+ def validation_callback?(method_name)
294
+ _validate_callbacks.map(&:filter).include?(method_name)
295
+ end
296
+ end
297
+ end
298
+
299
+ Dir[File.expand_path('context/*.rb', __dir__)].each { |file| require file }
300
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteractor
4
+ # Provides interactor methods to included classes
5
+ #
6
+ # @author Aaron Allen <hello@aaronmallen.me>
7
+ # @since 0.0.1
8
+ # @version 0.1
9
+ module Interactor
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ extend ClassMethods
14
+ include Callbacks
15
+ include Context
16
+ include Execution
17
+
18
+ private
19
+
20
+ attr_accessor :context
21
+ end
22
+
23
+ module ClassMethods
24
+ # Invoke an interactor. This is the primary public API method to an
25
+ # interactor.
26
+ #
27
+ # @example Run an interactor
28
+ # MyInteractor.perform(name: 'Aaron')
29
+ # #=> <#MyInteractor::Context name='Aaron'>
30
+ #
31
+ # @param context [Hash] properties to assign to the interactor context
32
+ # @return [ActiveInteractor::Context::Base] an instance of context
33
+ def perform(context = {})
34
+ new(context).execute_perform
35
+ end
36
+
37
+ # Invoke an Interactor. The {.perform!} method behaves identically to
38
+ # the {.perform} method with one notable exception. If the context is failed
39
+ # during invocation of the interactor, the {ActiveInteractor::Context::Failure}
40
+ # is raised.
41
+ #
42
+ # @example Run an interactor
43
+ # MyInteractor.perform!(name: 'Aaron')
44
+ # #=> <#MyInteractor::Context name='Aaron'>
45
+ #
46
+ # @param context [Hash] properties to assign to the interactor context
47
+ # @return [ActiveInteractor::Context::Base] an instance of context
48
+ def perform!(context = {})
49
+ new(context).execute_perform!
50
+ end
51
+ end
52
+
53
+ # Whether or not the context should fail when invalid
54
+ # this will return false if
55
+ # {Interactor::Callbacks::ClassMethods#allow_context_to_be_invalid}
56
+ # has been invoked on the class.
57
+ # @return [Boolean] `true` if the context should fail
58
+ # `false` if it should not.
59
+ def fail_on_invalid_context?
60
+ self.class.__fail_on_invalid_context
61
+ end
62
+
63
+ # Invoke an Interactor instance without any hooks, tracking, or rollback
64
+ # @abstract It is expected that the {#perform} method is overwritten
65
+ # for each interactor class.
66
+ def perform; end
67
+
68
+ # Reverse prior invocation of an Interactor instance.
69
+ # @abstract Any interactor class that requires undoing upon downstream
70
+ # failure is expected to overwrite the {#rollback} method.
71
+ def rollback; end
72
+
73
+ # Whether or not the context should be cleaned after {#perform}
74
+ # if {#skip_clean_context!} has not been invoked on the instance
75
+ # and {Interactor::Callbacks::ClassMethods#clean_context_on_completion}
76
+ # is invoked on the class this will return `true`.
77
+ #
78
+ # @return [Boolean] `true` if the context should be cleaned
79
+ # `false` if it should not be cleaned.
80
+ def should_clean_context?
81
+ @should_clean_context.nil? && self.class.__clean_after_perform
82
+ end
83
+
84
+ # Skip {ActiveInteractor::Context::Base#clean! #clean! on an interactor
85
+ # context that calls the {Callbacks.clean_context_on_completion} class method.
86
+ # This method is meant to be invoked by organizer interactors
87
+ # to ensure contexts are approriately passed between interactors.
88
+ #
89
+ # @return [Boolean] `true` if the context should be cleaned
90
+ # `false` if it should not.
91
+ def skip_clean_context!
92
+ @should_clean_context = false
93
+ end
94
+ end
95
+ end
96
+
97
+ Dir[File.expand_path('interactor/*.rb', __dir__)].each { |file| require file }