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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +54 -0
- data/CHANGELOG.md +5 -0
- data/CONTRIBUTING.md +37 -0
- data/LICENSE.txt +22 -0
- data/README.md +30 -0
- data/Rakefile +12 -0
- data/axn.gemspec +45 -0
- data/docs/.vitepress/config.mjs +55 -0
- data/docs/about/index.md +46 -0
- data/docs/advanced/rough.md +12 -0
- data/docs/advanced/validating-user-input.md +11 -0
- data/docs/guide/index.md +157 -0
- data/docs/index.md +26 -0
- data/docs/reference/action-result.md +12 -0
- data/docs/reference/class.md +18 -0
- data/docs/reference/configuration.md +90 -0
- data/docs/reference/instance.md +17 -0
- data/docs/usage/conventions.md +38 -0
- data/docs/usage/setup.md +26 -0
- data/docs/usage/testing.md +13 -0
- data/docs/usage/using.md +65 -0
- data/docs/usage/writing.md +118 -0
- data/lib/action/configuration.rb +46 -0
- data/lib/action/context_facade.rb +201 -0
- data/lib/action/contract.rb +190 -0
- data/lib/action/contract_validator.rb +66 -0
- data/lib/action/enqueueable.rb +74 -0
- data/lib/action/exceptions.rb +65 -0
- data/lib/action/hoist_errors.rb +51 -0
- data/lib/action/logging.rb +41 -0
- data/lib/action/organizer.rb +41 -0
- data/lib/action/swallow_exceptions.rb +104 -0
- data/lib/action/top_level_around_hook.rb +63 -0
- data/lib/axn/version.rb +5 -0
- data/lib/axn.rb +54 -0
- data/package.json +10 -0
- data/yarn.lock +1166 -0
- metadata +128 -0
@@ -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
|