axn 0.1.0.pre.alpha.1

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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+ require "active_support/core_ext/enumerable"
5
+ require "active_support/core_ext/module/delegation"
6
+
7
+ require "action/contract_validator"
8
+ require "action/context_facade"
9
+
10
+ module Action
11
+ module Contract
12
+ def self.included(base)
13
+ base.class_eval do
14
+ class_attribute :internal_field_configs, :external_field_configs, default: []
15
+
16
+ extend ClassMethods
17
+ include InstanceMethods
18
+ include ValidationInstanceMethods
19
+
20
+ # Remove public context accessor
21
+ remove_method :context
22
+
23
+ around do |hooked|
24
+ _apply_inbound_preprocessing!
25
+ _apply_defaults!(:inbound)
26
+ _validate_contract!(:inbound)
27
+ hooked.call
28
+ _apply_defaults!(:outbound)
29
+ _validate_contract!(:outbound)
30
+ end
31
+ end
32
+ end
33
+
34
+ FieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
35
+
36
+ module ClassMethods
37
+ def gets(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
38
+ **validations)
39
+ _parse_field_configs(*fields, allow_blank:, default:, preprocess:, sensitive:, **validations).tap do |configs|
40
+ duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
41
+ raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
42
+
43
+ # NOTE: avoid <<, which would update value for parents and children
44
+ self.internal_field_configs += configs
45
+ end
46
+ end
47
+
48
+ def sets(*fields, allow_blank: false, default: nil, sensitive: false, **validations)
49
+ _parse_field_configs(*fields, allow_blank:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
50
+ duplicated = external_field_configs.map(&:field) & configs.map(&:field)
51
+ raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
52
+
53
+ # NOTE: avoid <<, which would update value for parents and children
54
+ self.external_field_configs += configs
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def _parse_field_configs(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
61
+ **validations)
62
+ # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
63
+ # (e.g. to allow success message callable to reference exposed fields)
64
+ fields.each do |field|
65
+ define_method(field) { internal_context.public_send(field) }
66
+ end
67
+
68
+ if allow_blank
69
+ validations.transform_values! do |v|
70
+ v = { value: v } unless v.is_a?(Hash)
71
+ { allow_blank: true }.merge(v)
72
+ end
73
+ elsif validations.key?(:boolean)
74
+ validations[:presence] = false
75
+ else
76
+ validations[:presence] = true unless validations.key?(:presence)
77
+ end
78
+
79
+ fields.map { |field| FieldConfig.new(field:, validations:, default:, preprocess:, sensitive:) }
80
+ end
81
+ end
82
+
83
+ module InstanceMethods
84
+ def internal_context = @internal_context ||= _build_context_facade(:inbound)
85
+ def external_context = @external_context ||= _build_context_facade(:outbound)
86
+
87
+ # NOTE: ideally no direct access from client code, but we need to set this for internal Interactor methods
88
+ # (and passing through control methods to underlying context) in order to avoid rewriting internal methods.
89
+ def context = external_context
90
+
91
+ # Accepts either two positional arguments (key, value) or a hash of key/value pairs
92
+ def set(*args, **kwargs)
93
+ if args.any?
94
+ if args.size != 2
95
+ raise ArgumentError,
96
+ "set must be called with exactly two positional arguments (or a hash of key/value pairs)"
97
+ end
98
+
99
+ kwargs.merge!(args.first => args.last)
100
+ end
101
+
102
+ kwargs.each do |key, value|
103
+ raise Action::ContractViolation::UnknownExposure, key unless external_context.respond_to?(key)
104
+
105
+ @context.public_send("#{key}=", value)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def _build_context_facade(direction)
112
+ raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
113
+
114
+ klass = direction == :inbound ? Action::InternalContext : Action::Result
115
+ implicitly_allowed_fields = direction == :inbound ? declared_fields(:outbound) : []
116
+
117
+ klass.new(action: self, context: @context, declared_fields: declared_fields(direction), implicitly_allowed_fields:)
118
+ end
119
+ end
120
+
121
+ module ValidationInstanceMethods
122
+ def _apply_inbound_preprocessing!
123
+ internal_field_configs.each do |config|
124
+ next unless config.preprocess
125
+
126
+ initial_value = @context.public_send(config.field)
127
+ new_value = config.preprocess.call(initial_value)
128
+ @context.public_send("#{config.field}=", new_value)
129
+ rescue StandardError => e
130
+ raise Action::ContractViolation::PreprocessingError, "Error preprocessing field '#{config.field}': #{e.message}"
131
+ end
132
+ end
133
+
134
+ def _validate_contract!(direction)
135
+ raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
136
+
137
+ configs = direction == :inbound ? internal_field_configs : external_field_configs
138
+ validations = configs.each_with_object({}) do |config, hash|
139
+ hash[config.field] = config.validations
140
+ end
141
+ context = direction == :inbound ? internal_context : external_context
142
+ exception_klass = direction == :inbound ? Action::InboundValidationError : Action::OutboundValidationError
143
+
144
+ ContractValidator.validate!(validations:, context:, exception_klass:)
145
+ end
146
+
147
+ def _apply_defaults!(direction)
148
+ raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
149
+
150
+ configs = direction == :inbound ? internal_field_configs : external_field_configs
151
+ defaults_mapping = configs.each_with_object({}) do |config, hash|
152
+ hash[config.field] = config.default
153
+ end.compact
154
+
155
+ defaults_mapping.each do |field, default_value|
156
+ unless @context.public_send(field)
157
+ @context.public_send("#{field}=",
158
+ default_value.respond_to?(:call) ? default_value.call : default_value)
159
+ end
160
+ end
161
+ end
162
+
163
+ def context_for_logging(direction = nil)
164
+ inspection_filter.filter(@context.to_h.slice(*declared_fields(direction)))
165
+ end
166
+
167
+ protected
168
+
169
+ def inspection_filter
170
+ @inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
171
+ end
172
+
173
+ def sensitive_fields
174
+ (internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
175
+ end
176
+
177
+ def declared_fields(direction)
178
+ raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
179
+
180
+ configs = case direction
181
+ when :inbound then internal_field_configs
182
+ when :outbound then external_field_configs
183
+ else (internal_field_configs + external_field_configs)
184
+ end
185
+
186
+ configs.map(&:field)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ class ContractValidator
5
+ include ActiveModel::Validations
6
+
7
+ def initialize(context)
8
+ @context = context
9
+ end
10
+
11
+ def read_attribute_for_validation(attr)
12
+ @context.public_send(attr)
13
+ end
14
+
15
+ def self.validate!(validations:, context:, exception_klass:)
16
+ validator = Class.new(self) do
17
+ def self.name = "Action::ContractValidator::OneOff"
18
+
19
+ validations.each do |field, field_validations|
20
+ field_validations.each do |key, value|
21
+ validates field, key => value
22
+ end
23
+ end
24
+ end.new(context)
25
+
26
+ return if validator.valid?
27
+
28
+ raise exception_klass, validator.errors
29
+ end
30
+
31
+ # Allow for custom validators to be defined in the context of the action
32
+ class ValidateValidator < ActiveModel::EachValidator
33
+ def validate_each(record, attribute, value)
34
+ msg = begin
35
+ options[:with].call(value)
36
+ rescue StandardError => e
37
+ warn("Custom validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
38
+
39
+ "failed validation: #{e.message}"
40
+ end
41
+
42
+ record.errors.add(attribute, msg) if msg.present?
43
+ end
44
+ end
45
+
46
+ class BooleanValidator < ActiveModel::EachValidator
47
+ def validate_each(record, attribute, value)
48
+ return if [true, false].include?(value)
49
+
50
+ record.errors.add(attribute, "must be true or false")
51
+ end
52
+ end
53
+
54
+ class TypeValidator < ActiveModel::EachValidator
55
+ def validate_each(record, attribute, value)
56
+ return if value.blank? # Handled with a separate default presence validator
57
+
58
+ # TODO: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
59
+ types = options[:in].presence || Array(options[:with]).presence || Array(options[:value]).presence
60
+
61
+ msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
62
+ record.errors.add attribute, (options[:message] || msg) unless types.any? { |type| value.is_a?(type) }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Enqueueable
5
+ def self.included(base)
6
+ base.class_eval do
7
+ begin
8
+ require "sidekiq"
9
+ include Sidekiq::Job
10
+ rescue LoadError
11
+ puts "Sidekiq not available -- skipping Enqueueable"
12
+ return
13
+ end
14
+
15
+ define_method(:perform) do |*args|
16
+ context = self.class._params_from_global_id(args.first)
17
+ bang = args.size > 1 ? args.last : false
18
+
19
+ if bang
20
+ self.class.call!(context)
21
+ else
22
+ self.class.call(context)
23
+ end
24
+ end
25
+
26
+ def self.enqueue(context = {})
27
+ perform_async(_process_context_to_sidekiq_args(context))
28
+ end
29
+
30
+ def self.enqueue!(context = {})
31
+ perform_async(_process_context_to_sidekiq_args(context), true)
32
+ end
33
+
34
+ def self.queue_options(opts)
35
+ opts = opts.transform_keys(&:to_s)
36
+ self.sidekiq_options_hash = get_sidekiq_options.merge(opts)
37
+ end
38
+
39
+ private
40
+
41
+ def self._process_context_to_sidekiq_args(context)
42
+ client = Sidekiq::Client.new
43
+
44
+ _params_to_global_id(context).tap do |args|
45
+ if client.send(:json_unsafe?, args).present?
46
+ raise ArgumentError,
47
+ "Cannot pass non-JSON-serializable objects to Sidekiq. Make sure all objects in the context are serializable (or respond to to_global_id)."
48
+ end
49
+ end
50
+ end
51
+
52
+ def self._params_to_global_id(context)
53
+ context.stringify_keys.each_with_object({}) do |(key, value), hash|
54
+ if value.respond_to?(:to_global_id)
55
+ hash["#{key}_as_global_id"] = value.to_global_id.to_s
56
+ else
57
+ hash[key] = value
58
+ end
59
+ end
60
+ end
61
+
62
+ def self._params_from_global_id(params)
63
+ params.each_with_object({}) do |(key, value), hash|
64
+ if key.end_with?("_as_global_id")
65
+ hash[key.delete_suffix("_as_global_id")] = GlobalID::Locator.locate(value)
66
+ else
67
+ hash[key] = value
68
+ end
69
+ end.symbolize_keys
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ # Raised internally when fail! is called -- triggers failure + rollback handling
5
+ class Failure < StandardError
6
+ attr_reader :context
7
+
8
+ def initialize(context = nil, message: nil)
9
+ super()
10
+ @message = message if message.present?
11
+ @context = context
12
+ end
13
+
14
+ def message = @message.presence || "Execution was halted"
15
+ end
16
+
17
+ class StepsRequiredForInheritanceSupportError < StandardError
18
+ def message
19
+ <<~MSG
20
+ ** Inheritance support requires the following steps: **
21
+
22
+ Add this to your Gemfile:
23
+ gem "interactor", github: "kaspermeyer/interactor", branch: "fix-hook-inheritance"
24
+
25
+ Explanation:
26
+ Unfortunately the upstream interactor gem does not support inheritance of hooks, which is required for this feature.
27
+ This branch is a temporary fork that adds support for inheritance of hooks, but published gems cannot specify a branch dependency.
28
+ In the future we may inline the upstream Interactor gem entirely and remove this necessity, but while we're in alpha we're continuing
29
+ to use the upstream gem for stability (and there has been recent activity on the project, so they *may* be adding additional functionality
30
+ soon).
31
+ MSG
32
+ end
33
+ end
34
+
35
+ class ContractViolation < StandardError
36
+ class MethodNotAllowed < ContractViolation; end
37
+ class PreprocessingError < ContractViolation; end
38
+
39
+ class UnknownExposure < ContractViolation
40
+ def initialize(key)
41
+ @key = key
42
+ super()
43
+ end
44
+
45
+ def message = "Attempted to set unknown key '#{@key}': be sure to declare it with `sets :#{@key}`"
46
+ end
47
+ end
48
+
49
+ class DuplicateFieldError < ContractViolation; end
50
+
51
+ class ValidationError < ContractViolation
52
+ attr_reader :errors
53
+
54
+ def initialize(errors)
55
+ @errors = errors
56
+ super
57
+ end
58
+
59
+ def message = errors.full_messages.to_sentence
60
+ def to_s = message
61
+ end
62
+
63
+ class InboundValidationError < ValidationError; end
64
+ class OutboundValidationError < ValidationError; end
65
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module HoistErrors
5
+ def self.included(base)
6
+ base.class_eval do
7
+ include InstanceMethods
8
+ end
9
+ end
10
+
11
+ module InstanceMethods
12
+ private
13
+
14
+ MinimalFailedResult = Data.define(:error, :exception) do
15
+ def ok? = false
16
+ end
17
+
18
+ # This method is used to ensure that the result of a block is successful before proceeding.
19
+ #
20
+ # Assumes success unless the block raises an exception or returns a failed result.
21
+ # (i.e. if you wrap logic that is NOT an action call, it'll be successful unless it raises an exception)
22
+ def hoist_errors(prefix: nil)
23
+ raise ArgumentError, "#hoist_errors must be given a block to execute" unless block_given?
24
+
25
+ result = begin
26
+ yield
27
+ rescue StandardError => e
28
+ warn "hoist_errors block swallowed an exception: #{e.message}"
29
+ MinimalFailedResult.new(error: nil, exception: e)
30
+ end
31
+
32
+ # This ensures the last line of hoist_errors was an Action call (CAUTION: if there are multiple
33
+ # calls per block, only the last one will be checked!)
34
+ unless result.respond_to?(:ok?)
35
+ raise ArgumentError,
36
+ "#hoist_errors is expected to wrap an Action call, but it returned a #{result.class.name} instead"
37
+ end
38
+
39
+ _handle_hoisted_errors(result, prefix:) unless result.ok?
40
+ end
41
+
42
+ # Separate method to allow overriding in subclasses
43
+ def _handle_hoisted_errors(result, prefix: nil)
44
+ @context.exception = result.exception if result.exception.present?
45
+ @context.error_prefix = prefix if prefix.present?
46
+
47
+ fail! result.error
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ module Action
6
+ module Logging
7
+ LEVELS = %i[debug info warn error fatal].freeze
8
+
9
+ def self.included(base)
10
+ base.class_eval do
11
+ extend ClassMethods
12
+ delegate :log, *LEVELS, to: :class
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ def log(message, level: :info)
18
+ level = :info if level == :debug && _targeted_for_debug_logging?
19
+ msg = [_log_prefix, message].compact_blank.join(" ")
20
+
21
+ Action.config.logger.send(level, msg)
22
+ end
23
+
24
+ LEVELS.each do |level|
25
+ define_method(level) do |message|
26
+ log(message, level:)
27
+ end
28
+ end
29
+
30
+ # TODO: this is ugly, we should be able to override in the config class...
31
+ def _log_prefix = name == "Action::Configuration" ? nil : "[#{name || "Anonymous Class"}]"
32
+
33
+ def _targeted_for_debug_logging?
34
+ return true if Action.config.global_debug_logging?
35
+
36
+ target_class_names = (ENV["SA_DEBUG_TARGETS"] || "").split(",").map(&:strip)
37
+ target_class_names.include?(name)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # CAUTION - ALPHA - TODO -- this code is extremely rough -- we have not yet gotten around to using it in production, so
5
+ # consider this a work in progress / an indicator of things to come.
6
+ #
7
+
8
+ module Action
9
+ # NOTE: replaces, rather than layers on, the upstream Interactor::Organizer module (only three methods, and
10
+ # we want ability to implement a more complex interface where we pass options into the organized interactors)
11
+ module Organizer
12
+ def self.included(base)
13
+ base.class_eval do
14
+ include ::Action
15
+
16
+ extend ClassMethods
17
+ include InstanceMethods
18
+ end
19
+ end
20
+
21
+ # NOTE: pulled unchanged from https://github.com/collectiveidea/interactor/blob/master/lib/interactor/organizer.rb
22
+ module ClassMethods
23
+ def organize(*interactors)
24
+ @organized = interactors.flatten
25
+ end
26
+
27
+ def organized
28
+ @organized ||= []
29
+ end
30
+ end
31
+
32
+ module InstanceMethods
33
+ # NOTE: override to use the `hoist_errors` method (internally, replaces call! with call + overrides to use @context directly)
34
+ def call
35
+ self.class.organized.each do |interactor|
36
+ hoist_errors { interactor.call(@context) }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module SwallowExceptions
5
+ def self.included(base)
6
+ base.class_eval do
7
+ class_attribute :_success_msg, :_error_msg
8
+ class_attribute :_error_rescues, default: []
9
+
10
+ include InstanceMethods
11
+ extend ClassMethods
12
+
13
+ def run_with_exception_swallowing!
14
+ original_run!
15
+ rescue Action::Failure => e
16
+ # Just re-raise these (so we don't hit the unexpected-error case below)
17
+ raise e
18
+ rescue StandardError => e
19
+ # Add custom hook for intercepting exceptions (e.g. Teamshares automatically logs to Honeybadger)
20
+ trigger_on_exception(e)
21
+
22
+ @context.exception = e
23
+
24
+ fail!
25
+ end
26
+
27
+ alias_method :original_run!, :run!
28
+ alias_method :run!, :run_with_exception_swallowing!
29
+
30
+ # Tweaked to check @context.object_id rather than context (since forwarding object_id causes Ruby to complain)
31
+ # TODO: do we actually need the object_id check? Do we need this override at all?
32
+ def run
33
+ run!
34
+ rescue Action::Failure => e
35
+ raise if @context.object_id != e.context.object_id
36
+ end
37
+
38
+ def trigger_on_exception(e)
39
+ Action.config.on_exception(e,
40
+ action: self,
41
+ context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
42
+ rescue StandardError => e
43
+ # No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
44
+ # we don't want exception *handling* failures to cascade and overwrite the original exception.
45
+ warn("Ignoring #{e.class.name} in on_exception hook: #{e.message}")
46
+ end
47
+
48
+ class << base
49
+ def call_bang_with_unswallowed_exceptions(context = {})
50
+ result = call(context)
51
+ return result if result.ok?
52
+
53
+ raise result.exception if result.exception
54
+
55
+ raise Action::Failure.new(result.instance_variable_get("@context"), message: result.error)
56
+ end
57
+
58
+ alias_method :original_call!, :call!
59
+ alias_method :call!, :call_bang_with_unswallowed_exceptions
60
+ end
61
+ end
62
+ end
63
+
64
+ module ClassMethods
65
+ def messages(success: nil, error: nil)
66
+ self._success_msg = success if success.present?
67
+ self._error_msg = error if error.present?
68
+
69
+ true
70
+ end
71
+
72
+ def rescues(matcher = nil, message = nil, **match_and_messages)
73
+ raise ArgumentError, "rescues must be called with a key, value pair or else keyword args" if [matcher, message].compact.size == 1
74
+
75
+ { matcher => message }.compact.merge(match_and_messages).each { |mam| self._error_rescues += [mam] }
76
+ end
77
+
78
+ def default_error = new.internal_context.default_error
79
+ end
80
+
81
+ module InstanceMethods
82
+ private
83
+
84
+ def fail!(message = nil)
85
+ @context.instance_variable_set("@failure", true)
86
+ @context.error_from_user = message if message.present?
87
+
88
+ # TODO: should we use context_for_logging here? But doublecheck the one place where we're checking object_id on it...
89
+ raise Action::Failure.new(@context) # rubocop:disable Style/RaiseArgs
90
+ end
91
+
92
+ def try
93
+ yield
94
+ rescue Action::Failure => e
95
+ # NOTE: re-raising so we can still fail! from inside the block
96
+ raise e
97
+ rescue StandardError => e
98
+ trigger_on_exception(e)
99
+ end
100
+
101
+ delegate :default_error, to: :internal_context
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module TopLevelAroundHook
5
+ def self.included(base)
6
+ base.class_eval do
7
+ around :__top_level_around_hook
8
+
9
+ include InstanceMethods
10
+ end
11
+ end
12
+
13
+ module InstanceMethods
14
+ def __top_level_around_hook(hooked)
15
+ timing_start = Time.now
16
+ _log_before
17
+
18
+ _configurable_around_wrapper do
19
+ (@outcome, @exception) = _call_and_return_outcome(hooked)
20
+ end
21
+
22
+ _log_after(timing_start:, outcome: @outcome)
23
+
24
+ raise @exception if @exception
25
+ end
26
+
27
+ private
28
+
29
+ def _configurable_around_wrapper(&)
30
+ return yield unless Action.config.top_level_around_hook
31
+
32
+ Action.config.top_level_around_hook.call(self.class.name || "AnonymousClass", &)
33
+ end
34
+
35
+ def _log_before
36
+ debug [
37
+ "About to execute",
38
+ context_for_logging(:inbound).presence&.inspect,
39
+ ].compact.join(" with: ")
40
+ end
41
+
42
+ def _log_after(outcome:, timing_start:)
43
+ elapsed_mils = ((Time.now - timing_start) * 1000).round(3)
44
+
45
+ debug [
46
+ "Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
47
+ context_for_logging(:outbound).presence&.inspect,
48
+ ].compact.join(". Set: ")
49
+ end
50
+
51
+ def _call_and_return_outcome(hooked)
52
+ hooked.call
53
+
54
+ "success"
55
+ rescue StandardError => e
56
+ [
57
+ e.is_a?(Action::Failure) ? "failure" : "exception",
58
+ e,
59
+ ]
60
+ end
61
+ end
62
+ end
63
+ end