axn 0.1.0.pre.alpha.1.1 → 0.1.0.pre.alpha.2
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 +4 -4
- data/.rubocop.yml +1 -1
- data/.tool-versions +1 -0
- data/CHANGELOG.md +10 -2
- data/CONTRIBUTING.md +1 -1
- data/README.md +1 -1
- data/docs/.vitepress/config.mjs +18 -10
- data/docs/advanced/rough.md +2 -0
- data/docs/index.md +11 -3
- data/docs/{guide/index.md → intro/overview.md} +11 -32
- data/docs/recipes/memoization.md +46 -0
- data/docs/{usage → recipes}/testing.md +4 -2
- data/docs/reference/action-result.md +32 -9
- data/docs/reference/class.md +69 -12
- data/docs/reference/configuration.md +28 -15
- data/docs/reference/instance.md +96 -13
- data/docs/usage/setup.md +0 -2
- data/docs/usage/using.md +7 -15
- data/docs/usage/writing.md +45 -6
- data/lib/action/attachable/base.rb +43 -0
- data/lib/action/attachable/steps.rb +47 -0
- data/lib/action/attachable/subactions.rb +43 -0
- data/lib/action/attachable.rb +17 -0
- data/lib/action/{configuration.rb → core/configuration.rb} +1 -1
- data/lib/action/{context_facade.rb → core/context_facade.rb} +18 -28
- data/lib/action/{contract.rb → core/contract.rb} +10 -2
- data/lib/action/{exceptions.rb → core/exceptions.rb} +11 -0
- data/lib/action/{hoist_errors.rb → core/hoist_errors.rb} +3 -2
- data/lib/action/{swallow_exceptions.rb → core/swallow_exceptions.rb} +52 -7
- data/lib/axn/factory.rb +102 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +20 -10
- metadata +28 -22
- data/lib/action/organizer.rb +0 -41
- /data/docs/{usage → advanced}/conventions.md +0 -0
- /data/docs/{about/index.md → intro/about.md} +0 -0
- /data/docs/{advanced → recipes}/validating-user-input.md +0 -0
- /data/lib/action/{contract_validator.rb → core/contract_validator.rb} +0 -0
- /data/lib/action/{enqueueable.rb → core/enqueueable.rb} +0 -0
- /data/lib/action/{logging.rb → core/logging.rb} +0 -0
- /data/lib/action/{top_level_around_hook.rb → core/top_level_around_hook.rb} +0 -0
data/docs/usage/using.md
CHANGED
@@ -7,11 +7,11 @@ outline: deep
|
|
7
7
|
|
8
8
|
## Common Case
|
9
9
|
|
10
|
-
An action
|
10
|
+
An action executed via `#call` _always_ returns an instance of the `Action::Result` class.
|
11
11
|
|
12
|
-
This means the result _always_ implements a consistent interface, including `ok?` and `error` (see [full details](/reference/action-result)) as well as any variables that
|
12
|
+
This means the result _always_ implements a consistent interface, including `ok?` and `error` (see [full details](/reference/action-result)) as well as any variables that the action `exposes`.
|
13
13
|
|
14
|
-
As a consumer, you usually want a conditional that surfaces `error` unless the result is `ok
|
14
|
+
As a consumer, you usually want a conditional that surfaces `error` unless the result is `ok?` (remember that any exceptions have been swallowed), and otherwise takes whatever success action is relevant.
|
15
15
|
|
16
16
|
For example:
|
17
17
|
|
@@ -23,9 +23,9 @@ class MessagesController < ApplicationController
|
|
23
23
|
message: params[:message],
|
24
24
|
)
|
25
25
|
|
26
|
-
if result.ok? # [!code focus:
|
26
|
+
if result.ok? # [!code focus:3]
|
27
27
|
@thread_id = result.thread_id # Because `thread_id` was explicitly exposed
|
28
|
-
flash.now[:success] =
|
28
|
+
flash.now[:success] = result.success
|
29
29
|
else
|
30
30
|
flash[:alert] = result.error # [!code focus]
|
31
31
|
redirect_to action: :new
|
@@ -34,21 +34,13 @@ class MessagesController < ApplicationController
|
|
34
34
|
end
|
35
35
|
```
|
36
36
|
|
37
|
-
<!-- TODO: replace manual flash success with result.success (here and in guide?) -->
|
38
|
-
|
39
|
-
|
40
37
|
## Advanced Usage
|
41
38
|
|
42
39
|
### `#call!`
|
43
40
|
|
44
|
-
|
45
|
-
* TODO - flesh out this section
|
46
|
-
:::
|
47
|
-
|
41
|
+
An action executed via `#call!` (note the `!`) does _not_ swallow exceptions -- a _successful_ action will return an `Action::Result` just like `call`, but any exceptions will bubble up uncaught (note: technically they _will_ be caught, your on_exception handler triggered, and then re-raised) and any explicit `fail!` calls will raise an `Action::Failure` exception with your custom message.
|
48
42
|
|
49
|
-
|
50
|
-
* call! -- will raise any exceptions OR our own Action::Failure if user-facing error occurred (otherwise non-bang will never raise)
|
51
|
-
* note call! still logs completion even if failure (from configuration's on_exception)
|
43
|
+
This is a much less common pattern, as you're giving up the benefits of error swallowing and the consistent return interface guarantee, but it can be useful in limited contexts (usually for smaller, one-off scripts where it's easier to just let a failure bubble up rather than worry about adding conditionals for error handling).
|
52
44
|
|
53
45
|
|
54
46
|
### `#enqueue`
|
data/docs/usage/writing.md
CHANGED
@@ -66,10 +66,39 @@ See [the reference doc](/reference/instance) for a few more handy helper methods
|
|
66
66
|
|
67
67
|
## Customizing messages
|
68
68
|
|
69
|
-
|
70
|
-
|
71
|
-
|
69
|
+
The default `error` and `success` message strings ("Something went wrong" / "Action completed successfully", respectively) _are_ technically safe to show users, but you'll often want to set them to something more useful.
|
70
|
+
|
71
|
+
There's a `messages` declaration for that -- you can set strings (most common) or a callable (note for the error case, if you give it a callable that expects a single argument, the exception that was raised will be passed in).
|
72
|
+
|
73
|
+
For instance, configuring the action like this:
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
class Foo
|
77
|
+
include Action
|
78
|
+
|
79
|
+
expects :name, type: String
|
80
|
+
exposes :meaning_of_life
|
81
|
+
|
82
|
+
messages success: -> { "Revealed the secret of life to #{name}" }, # [!code focus:2]
|
83
|
+
error: ->(e) { "No secret of life for you: #{e.message}" }
|
84
|
+
|
85
|
+
def call
|
86
|
+
fail! "Douglas already knows the meaning" if name == "Doug"
|
72
87
|
|
88
|
+
msg = "Hello #{name}, the meaning of life is 42"
|
89
|
+
expose meaning_of_life: msg
|
90
|
+
end
|
91
|
+
end
|
92
|
+
```
|
93
|
+
|
94
|
+
Would give us these outputs:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
Foo.call.error # => "No secret of life for you: Name can't be blank"
|
98
|
+
Foo.call(name: "Doug").error # => "Douglas already knows the meaning"
|
99
|
+
Foo.call(name: "Adams").success # => "Revealed the secret of life to Adams"
|
100
|
+
Foo.call(name: "Adams").meaning_of_life # => "Hello Adams, the meaning of life is 42"
|
101
|
+
```
|
73
102
|
|
74
103
|
## Lifecycle methods
|
75
104
|
|
@@ -77,11 +106,15 @@ In addition to `#call`, there are a few additional pieces to be aware of:
|
|
77
106
|
|
78
107
|
### `#rollback`
|
79
108
|
|
109
|
+
::: danger ALPHA
|
110
|
+
* ⚠️ `#rollback` is _expected_ to be added shortly, but is not yet functional!
|
111
|
+
:::
|
112
|
+
|
80
113
|
If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails.
|
81
114
|
|
82
115
|
### Hooks
|
83
116
|
|
84
|
-
`before
|
117
|
+
`before` and `after` hooks are also supported. They can receive a block directly, or the symbol name of a local method.
|
85
118
|
|
86
119
|
### Concrete example
|
87
120
|
|
@@ -91,8 +124,8 @@ Given this series of methods and hooks:
|
|
91
124
|
class Foo
|
92
125
|
include Action
|
93
126
|
|
94
|
-
before { log("before hook") }
|
95
|
-
after
|
127
|
+
before { log("before hook") } # [!code focus:2]
|
128
|
+
after :log_after
|
96
129
|
|
97
130
|
def call
|
98
131
|
log("in call")
|
@@ -102,6 +135,12 @@ class Foo
|
|
102
135
|
def rollback
|
103
136
|
log("rolling back")
|
104
137
|
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def log_after
|
142
|
+
log("after hook")
|
143
|
+
end
|
105
144
|
end
|
106
145
|
```
|
107
146
|
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Action
|
4
|
+
module Attachable
|
5
|
+
module Base
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def axn_for_attachment(
|
10
|
+
attachment_type: "Action",
|
11
|
+
name: nil,
|
12
|
+
axn_klass: nil,
|
13
|
+
superclass: nil,
|
14
|
+
**kwargs,
|
15
|
+
&block
|
16
|
+
)
|
17
|
+
raise ArgumentError, "#{attachment_type} name must be a string or symbol" unless name.is_a?(String) || name.is_a?(Symbol)
|
18
|
+
raise ArgumentError, "#{attachment_type} '#{name}' must be given an existing action class or a block" if axn_klass.nil? && !block_given?
|
19
|
+
|
20
|
+
if axn_klass && block_given?
|
21
|
+
raise ArgumentError,
|
22
|
+
"#{attachment_type} '#{name}' was given both an existing action class and a block - only one is allowed"
|
23
|
+
end
|
24
|
+
|
25
|
+
if axn_klass
|
26
|
+
unless axn_klass.respond_to?(:<) && axn_klass < Action
|
27
|
+
raise ArgumentError,
|
28
|
+
"#{attachment_type} '#{name}' was given an already-existing class #{axn_klass.name} that does NOT inherit from Action as expected"
|
29
|
+
end
|
30
|
+
|
31
|
+
if kwargs.present?
|
32
|
+
raise ArgumentError, "#{attachment_type} '#{name}' was given an existing action class and also keyword arguments - only one is allowed"
|
33
|
+
end
|
34
|
+
|
35
|
+
return axn_klass
|
36
|
+
end
|
37
|
+
|
38
|
+
Axn::Factory.build(superclass: superclass || self, **kwargs, &block)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Action
|
4
|
+
module Attachable
|
5
|
+
module Steps
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
class_attribute :_axn_steps, default: []
|
10
|
+
end
|
11
|
+
|
12
|
+
Entry = Data.define(:label, :axn)
|
13
|
+
|
14
|
+
class_methods do
|
15
|
+
def steps(*steps)
|
16
|
+
self._axn_steps += Array(steps).compact
|
17
|
+
end
|
18
|
+
|
19
|
+
def step(name, axn_klass = nil, **kwargs, &block)
|
20
|
+
axn_klass = axn_for_attachment(
|
21
|
+
name:,
|
22
|
+
axn_klass:,
|
23
|
+
attachment_type: "Step",
|
24
|
+
superclass: Object, # NOTE: steps skip inheriting from the wrapping class (to avoid duplicate field expectations/exposures)
|
25
|
+
**kwargs,
|
26
|
+
&block
|
27
|
+
)
|
28
|
+
|
29
|
+
# Add the step to the list of steps
|
30
|
+
steps Entry.new(label: name, axn: axn_klass)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def call
|
35
|
+
self.class._axn_steps.each_with_index do |step, idx|
|
36
|
+
# Set a default label if we were just given an array of unlabeled steps
|
37
|
+
# TODO: should Axn have a default label passed in already that we could pull out?
|
38
|
+
step = Entry.new(label: "Step #{idx + 1}", axn: step) if step.is_a?(Class)
|
39
|
+
|
40
|
+
hoist_errors(prefix: "#{step.label} step") do
|
41
|
+
step.axn.call(@context)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Action
|
4
|
+
module Attachable
|
5
|
+
module Subactions
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def axnable_method(name, axn_klass = nil, **action_kwargs, &block)
|
10
|
+
raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
|
11
|
+
|
12
|
+
action_kwargs[:expose_return_as] ||= :value
|
13
|
+
axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
|
14
|
+
|
15
|
+
define_singleton_method("#{name}_axn") do |**kwargs|
|
16
|
+
axn_klass.call(**kwargs)
|
17
|
+
end
|
18
|
+
|
19
|
+
define_singleton_method("#{name}!") do |**kwargs|
|
20
|
+
result = axn_klass.call!(**kwargs)
|
21
|
+
result.public_send(action_kwargs[:expose_return_as])
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def axn(name, axn_klass = nil, **action_kwargs, &block)
|
26
|
+
raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
|
27
|
+
|
28
|
+
axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
|
29
|
+
|
30
|
+
define_singleton_method(name) do |**kwargs|
|
31
|
+
axn_klass.call(**kwargs)
|
32
|
+
end
|
33
|
+
|
34
|
+
# TODO: do we also need an instance-level version that auto-wraps in hoist_errors(label: name)?
|
35
|
+
|
36
|
+
define_singleton_method("#{name}!") do |**kwargs|
|
37
|
+
axn_klass.call!(**kwargs)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "attachable/base"
|
4
|
+
require_relative "attachable/steps"
|
5
|
+
require_relative "attachable/subactions"
|
6
|
+
|
7
|
+
module Action
|
8
|
+
module Attachable
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
|
11
|
+
included do
|
12
|
+
include Base
|
13
|
+
include Steps
|
14
|
+
include Subactions
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -12,7 +12,7 @@ module Action
|
|
12
12
|
|
13
13
|
def on_exception(e, action:, context: {})
|
14
14
|
if @on_exception
|
15
|
-
# TODO: only pass action: or context: if requested
|
15
|
+
# TODO: only pass action: or context: if requested (and update documentation)
|
16
16
|
@on_exception.call(e, action:, context:)
|
17
17
|
else
|
18
18
|
log("[#{action.class.name.presence || "Anonymous Action"}] Exception swallowed: #{e.class.name} - #{e.message}")
|
@@ -51,36 +51,15 @@ module Action
|
|
51
51
|
def determine_error_message(only_default: false)
|
52
52
|
return @context.error_from_user if @context.error_from_user.present?
|
53
53
|
|
54
|
+
exception = @context.exception || (only_default ? Action::Failure.new(@context) : nil)
|
55
|
+
msg = action._error_msg
|
56
|
+
|
54
57
|
unless only_default
|
55
|
-
|
56
|
-
|
58
|
+
interceptor = action.class._error_interceptor_for(exception:, action:)
|
59
|
+
msg = interceptor.message if interceptor
|
57
60
|
end
|
58
61
|
|
59
|
-
|
60
|
-
stringified(action._error_msg, exception: the_exception).presence || "Something went wrong"
|
61
|
-
end
|
62
|
-
|
63
|
-
def message_from_rescues
|
64
|
-
Array(action._error_rescues).each do |(matcher, value)|
|
65
|
-
matches = if matcher.respond_to?(:call)
|
66
|
-
if matcher.arity == 1
|
67
|
-
!!action.instance_exec(exception, &matcher)
|
68
|
-
else
|
69
|
-
!!action.instance_exec(&matcher)
|
70
|
-
end
|
71
|
-
elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
|
72
|
-
klass = Object.const_get(matcher.to_s)
|
73
|
-
klass && exception.is_a?(klass)
|
74
|
-
elsif matcher < Exception
|
75
|
-
exception.is_a?(matcher)
|
76
|
-
else
|
77
|
-
action.warn("Ignoring matcher #{matcher.inspect} in rescues command")
|
78
|
-
end
|
79
|
-
|
80
|
-
return stringified(value, exception:) if matches
|
81
|
-
end
|
82
|
-
|
83
|
-
nil
|
62
|
+
stringified(msg, exception:).presence || "Something went wrong"
|
84
63
|
end
|
85
64
|
|
86
65
|
# Allow for callable OR string messages
|
@@ -113,11 +92,22 @@ module Action
|
|
113
92
|
|
114
93
|
# Outbound / External ContextFacade
|
115
94
|
class Result < ContextFacade
|
95
|
+
# For ease of mocking return results in tests
|
96
|
+
class << self
|
97
|
+
def ok = Class.new { include(Action) }.call
|
98
|
+
|
99
|
+
def error(msg = "Something went wrong")
|
100
|
+
Class.new { include(Action) }.tap do |klass|
|
101
|
+
klass.define_method(:call) { fail!(msg) }
|
102
|
+
end.call
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
116
106
|
# Poke some holes for necessary internal control methods
|
117
107
|
delegate :called!, :rollback!, :each_pair, to: :context
|
118
108
|
|
119
109
|
# External interface
|
120
|
-
delegate :success?, :
|
110
|
+
delegate :success?, :exception, to: :context
|
121
111
|
def ok? = success?
|
122
112
|
|
123
113
|
def error
|
@@ -4,8 +4,8 @@ require "active_model"
|
|
4
4
|
require "active_support/core_ext/enumerable"
|
5
5
|
require "active_support/core_ext/module/delegation"
|
6
6
|
|
7
|
-
require "action/contract_validator"
|
8
|
-
require "action/context_facade"
|
7
|
+
require "action/core/contract_validator"
|
8
|
+
require "action/core/context_facade"
|
9
9
|
|
10
10
|
module Action
|
11
11
|
module Contract
|
@@ -57,11 +57,19 @@ module Action
|
|
57
57
|
|
58
58
|
private
|
59
59
|
|
60
|
+
RESERVED_FIELD_NAMES = %w[
|
61
|
+
declared_fields inspect fail!
|
62
|
+
default_error
|
63
|
+
called! rollback! each_pair success? exception ok ok? error success message
|
64
|
+
].freeze
|
65
|
+
|
60
66
|
def _parse_field_configs(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
|
61
67
|
**validations)
|
62
68
|
# Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
|
63
69
|
# (e.g. to allow success message callable to reference exposed fields)
|
64
70
|
fields.each do |field|
|
71
|
+
raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES.include?(field.to_s)
|
72
|
+
|
65
73
|
define_method(field) { internal_context.public_send(field) }
|
66
74
|
end
|
67
75
|
|
@@ -12,6 +12,8 @@ module Action
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def message = @message.presence || "Execution was halted"
|
15
|
+
|
16
|
+
def inspect = "#<#{self.class.name} '#{message}'>"
|
15
17
|
end
|
16
18
|
|
17
19
|
class StepsRequiredForInheritanceSupportError < StandardError
|
@@ -33,6 +35,15 @@ module Action
|
|
33
35
|
end
|
34
36
|
|
35
37
|
class ContractViolation < StandardError
|
38
|
+
class ReservedAttributeError < ContractViolation
|
39
|
+
def initialize(name)
|
40
|
+
@name = name
|
41
|
+
super()
|
42
|
+
end
|
43
|
+
|
44
|
+
def message = "Cannot call expects or exposes with reserved field name: #{@name}"
|
45
|
+
end
|
46
|
+
|
36
47
|
class MethodNotAllowed < ContractViolation; end
|
37
48
|
class PreprocessingError < ContractViolation; end
|
38
49
|
|
@@ -25,7 +25,7 @@ module Action
|
|
25
25
|
result = begin
|
26
26
|
yield
|
27
27
|
rescue StandardError => e
|
28
|
-
|
28
|
+
log "hoist_errors block transforming a #{e.class.name} exception: #{e.message}"
|
29
29
|
MinimalFailedResult.new(error: nil, exception: e)
|
30
30
|
end
|
31
31
|
|
@@ -44,7 +44,8 @@ module Action
|
|
44
44
|
@context.exception = result.exception if result.exception.present?
|
45
45
|
@context.error_prefix = prefix if prefix.present?
|
46
46
|
|
47
|
-
|
47
|
+
error = result.exception.is_a?(Action::Failure) ? result.exception.message : result.error
|
48
|
+
fail! error
|
48
49
|
end
|
49
50
|
end
|
50
51
|
end
|
@@ -2,20 +2,43 @@
|
|
2
2
|
|
3
3
|
module Action
|
4
4
|
module SwallowExceptions
|
5
|
+
CustomErrorInterceptor = Data.define(:matcher, :message, :should_report_error)
|
6
|
+
class CustomErrorInterceptor
|
7
|
+
def matches?(exception:, action:)
|
8
|
+
if matcher.respond_to?(:call)
|
9
|
+
if matcher.arity == 1
|
10
|
+
!!action.instance_exec(exception, &matcher)
|
11
|
+
else
|
12
|
+
!!action.instance_exec(&matcher)
|
13
|
+
end
|
14
|
+
elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
|
15
|
+
klass = Object.const_get(matcher.to_s)
|
16
|
+
klass && exception.is_a?(klass)
|
17
|
+
elsif matcher < Exception
|
18
|
+
exception.is_a?(matcher)
|
19
|
+
else
|
20
|
+
action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
|
21
|
+
false
|
22
|
+
end
|
23
|
+
rescue StandardError => e
|
24
|
+
action.warn("Ignoring #{e.class.name} raised while determining matcher: #{e.message}")
|
25
|
+
false
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
5
29
|
def self.included(base)
|
6
30
|
base.class_eval do
|
7
31
|
class_attribute :_success_msg, :_error_msg
|
8
|
-
class_attribute :
|
32
|
+
class_attribute :_custom_error_interceptors, default: []
|
9
33
|
|
10
34
|
include InstanceMethods
|
11
35
|
extend ClassMethods
|
12
36
|
|
13
37
|
def run_with_exception_swallowing!
|
14
38
|
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
39
|
rescue StandardError => e
|
40
|
+
raise if e.is_a?(Action::Failure) # TODO: avoid raising if this was passed along from a child action (esp. if wrapped in hoist_errors)
|
41
|
+
|
19
42
|
# Add custom hook for intercepting exceptions (e.g. Teamshares automatically logs to Honeybadger)
|
20
43
|
trigger_on_exception(e)
|
21
44
|
|
@@ -36,6 +59,9 @@ module Action
|
|
36
59
|
end
|
37
60
|
|
38
61
|
def trigger_on_exception(e)
|
62
|
+
interceptor = self.class._error_interceptor_for(exception: e, action: self)
|
63
|
+
return if interceptor&.should_report_error == false
|
64
|
+
|
39
65
|
Action.config.on_exception(e,
|
40
66
|
action: self,
|
41
67
|
context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
|
@@ -69,13 +95,32 @@ module Action
|
|
69
95
|
true
|
70
96
|
end
|
71
97
|
|
72
|
-
def
|
73
|
-
|
98
|
+
def error_from(matcher = nil, message = nil, **match_and_messages)
|
99
|
+
_register_error_interceptor(matcher, message, should_report_error: true, **match_and_messages)
|
100
|
+
end
|
74
101
|
|
75
|
-
|
102
|
+
def rescues(matcher = nil, message = nil, **match_and_messages)
|
103
|
+
_register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
|
76
104
|
end
|
77
105
|
|
78
106
|
def default_error = new.internal_context.default_error
|
107
|
+
|
108
|
+
# Private helpers
|
109
|
+
|
110
|
+
def _error_interceptor_for(exception:, action:)
|
111
|
+
Array(_custom_error_interceptors).detect do |int|
|
112
|
+
int.matches?(exception:, action:)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def _register_error_interceptor(matcher, message, should_report_error:, **match_and_messages)
|
117
|
+
method_name = should_report_error ? "error_from" : "rescues"
|
118
|
+
raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
|
119
|
+
|
120
|
+
{ matcher => message }.compact.merge(match_and_messages).each do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
|
121
|
+
self._custom_error_interceptors += [CustomErrorInterceptor.new(matcher:, message:, should_report_error:)]
|
122
|
+
end
|
123
|
+
end
|
79
124
|
end
|
80
125
|
|
81
126
|
module InstanceMethods
|
data/lib/axn/factory.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Axn
|
4
|
+
class Factory
|
5
|
+
class << self
|
6
|
+
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
|
7
|
+
def build(
|
8
|
+
# Builder-specific options
|
9
|
+
superclass: nil,
|
10
|
+
expose_return_as: :nil,
|
11
|
+
|
12
|
+
# Expose standard class-level options
|
13
|
+
exposes: {},
|
14
|
+
expects: {},
|
15
|
+
messages: {},
|
16
|
+
before: nil,
|
17
|
+
after: nil,
|
18
|
+
around: nil,
|
19
|
+
|
20
|
+
# Allow dynamically assigning rollback method
|
21
|
+
rollback: nil,
|
22
|
+
&block
|
23
|
+
)
|
24
|
+
args = block.parameters.each_with_object(_hash_with_default_array) { |(type, name), hash| hash[type] << name }
|
25
|
+
|
26
|
+
if args[:opt].present? || args[:req].present? || args[:rest].present?
|
27
|
+
raise ArgumentError,
|
28
|
+
"[Axn::Factory] Cannot convert block to action: block expects positional arguments"
|
29
|
+
end
|
30
|
+
raise ArgumentError, "[Axn::Factory] Cannot convert block to action: block expects a splat of keyword arguments" if args[:keyrest].present?
|
31
|
+
|
32
|
+
# TODO: is there any way to support default arguments? (if so, set allow_blank: true for those)
|
33
|
+
if args[:key].present?
|
34
|
+
raise ArgumentError,
|
35
|
+
"[Axn::Factory] Cannot convert block to action: block expects keyword arguments with defaults (ruby does not allow introspecting)"
|
36
|
+
end
|
37
|
+
|
38
|
+
expects = _hydrate_hash(expects)
|
39
|
+
exposes = _hydrate_hash(exposes)
|
40
|
+
|
41
|
+
Array(args[:keyreq]).each do |name|
|
42
|
+
expects[name] ||= {}
|
43
|
+
end
|
44
|
+
|
45
|
+
# NOTE: inheriting from wrapping class, so we can set default values (e.g. for HTTP headers)
|
46
|
+
Class.new(superclass || Object) do
|
47
|
+
include Action unless self < Action
|
48
|
+
|
49
|
+
define_method(:call) do
|
50
|
+
unwrapped_kwargs = Array(args[:keyreq]).each_with_object({}) do |name, hash|
|
51
|
+
hash[name] = public_send(name)
|
52
|
+
end
|
53
|
+
|
54
|
+
retval = instance_exec(**unwrapped_kwargs, &block)
|
55
|
+
expose(expose_return_as => retval) if expose_return_as.present?
|
56
|
+
end
|
57
|
+
end.tap do |axn| # rubocop: disable Style/MultilineBlockChain
|
58
|
+
expects.each do |name, opts|
|
59
|
+
axn.expects(name, **opts)
|
60
|
+
end
|
61
|
+
|
62
|
+
exposes.each do |name, opts|
|
63
|
+
axn.exposes(name, **opts)
|
64
|
+
end
|
65
|
+
|
66
|
+
axn.messages(**messages) if messages.present?
|
67
|
+
|
68
|
+
# Hooks
|
69
|
+
axn.before(before) if before.present?
|
70
|
+
axn.after(after) if after.present?
|
71
|
+
axn.around(around) if around.present?
|
72
|
+
|
73
|
+
# Rollback
|
74
|
+
if rollback.present?
|
75
|
+
raise ArgumentError, "[Axn::Factory] Rollback must be a callable" unless rollback.respond_to?(:call) && rollback.respond_to?(:arity)
|
76
|
+
raise ArgumentError, "[Axn::Factory] Rollback must be a callable with no arguments" unless rollback.arity.zero?
|
77
|
+
|
78
|
+
axn.define_method(:rollback) do
|
79
|
+
instance_exec(&rollback)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Default exposure
|
84
|
+
axn.exposes(expose_return_as, allow_blank: true) if expose_return_as.present?
|
85
|
+
end
|
86
|
+
end
|
87
|
+
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def _hash_with_default_array = Hash.new { |h, k| h[k] = [] }
|
92
|
+
|
93
|
+
def _hydrate_hash(given)
|
94
|
+
return given if given.is_a?(Hash)
|
95
|
+
|
96
|
+
Array(given).each_with_object({}) do |key, acc|
|
97
|
+
acc[key] = {}
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
data/lib/axn/version.rb
CHANGED