axn 0.1.0.pre.alpha.1.1 → 0.1.0.pre.alpha.2.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/.tool-versions +1 -0
  4. data/CHANGELOG.md +16 -2
  5. data/CONTRIBUTING.md +1 -1
  6. data/README.md +1 -1
  7. data/docs/.vitepress/config.mjs +18 -10
  8. data/docs/advanced/rough.md +2 -0
  9. data/docs/index.md +11 -3
  10. data/docs/{guide/index.md → intro/overview.md} +11 -32
  11. data/docs/recipes/memoization.md +46 -0
  12. data/docs/{usage → recipes}/testing.md +4 -2
  13. data/docs/reference/action-result.md +32 -9
  14. data/docs/reference/class.md +70 -12
  15. data/docs/reference/configuration.md +28 -15
  16. data/docs/reference/instance.md +96 -13
  17. data/docs/usage/setup.md +0 -2
  18. data/docs/usage/using.md +7 -15
  19. data/docs/usage/writing.md +50 -8
  20. data/lib/action/attachable/base.rb +43 -0
  21. data/lib/action/attachable/steps.rb +47 -0
  22. data/lib/action/attachable/subactions.rb +70 -0
  23. data/lib/action/attachable.rb +17 -0
  24. data/lib/action/{configuration.rb → core/configuration.rb} +1 -1
  25. data/lib/action/{context_facade.rb → core/context_facade.rb} +18 -28
  26. data/lib/action/{contract.rb → core/contract.rb} +21 -10
  27. data/lib/action/{contract_validator.rb → core/contract_validator.rb} +9 -11
  28. data/lib/action/{exceptions.rb → core/exceptions.rb} +11 -0
  29. data/lib/action/{hoist_errors.rb → core/hoist_errors.rb} +3 -2
  30. data/lib/action/{swallow_exceptions.rb → core/swallow_exceptions.rb} +52 -7
  31. data/lib/action/{top_level_around_hook.rb → core/top_level_around_hook.rb} +12 -1
  32. data/lib/axn/factory.rb +116 -0
  33. data/lib/axn/version.rb +1 -1
  34. data/lib/axn.rb +20 -10
  35. metadata +28 -22
  36. data/lib/action/organizer.rb +0 -41
  37. /data/docs/{usage → advanced}/conventions.md +0 -0
  38. /data/docs/{about/index.md → intro/about.md} +0 -0
  39. /data/docs/{advanced → recipes}/validating-user-input.md +0 -0
  40. /data/lib/action/{enqueueable.rb → core/enqueueable.rb} +0 -0
  41. /data/lib/action/{logging.rb → core/logging.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 is usually executed via `#call`, and _always_ returns an instance of the `Action::Result` class.
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 it `exposes`. Remember any exceptions have been swallowed.
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?`, and otherwise takes whatever success action is relevant.
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:2]
26
+ if result.ok? # [!code focus:3]
27
27
  @thread_id = result.thread_id # Because `thread_id` was explicitly exposed
28
- flash.now[:success] = "Sent the Slack message"
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
- ::: danger ALPHA
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
- * `call!`
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`
@@ -22,7 +22,7 @@ The first step is to determine what arguments you expect to be passed into `call
22
22
 
23
23
  If you want to expose any results to the caller, declare that via the `exposes` keyword.
24
24
 
25
- Both of these optionally accept `type:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
25
+ Both of these optionally accept `type:`, `allow_nil:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
26
26
 
27
27
 
28
28
  ```ruby
@@ -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
- ::: danger ALPHA
70
- * TODO: document `messages` setup
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
72
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"
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,17 @@ 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`, `after`, and `around` hooks are also supported.
117
+ `before` and `after` hooks are also supported. They can receive a block directly, or the symbol name of a local method.
118
+
119
+ Note execution is halted whenever `fail!` is called or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `resuilt.ok?` be false even though `call` completed successfully).
85
120
 
86
121
  ### Concrete example
87
122
 
@@ -91,17 +126,24 @@ Given this series of methods and hooks:
91
126
  class Foo
92
127
  include Action
93
128
 
94
- before { log("before hook") }
95
- after { log("after hook") }
129
+ before { log("before hook") } # [!code focus:2]
130
+ after :log_after
96
131
 
97
132
  def call
98
133
  log("in call")
99
- raise "oh no something borked"
100
134
  end
101
135
 
102
136
  def rollback
103
137
  log("rolling back")
104
138
  end
139
+
140
+ private
141
+
142
+ def log_after
143
+ log("after hook")
144
+ raise "oh no something borked"
145
+ log("after after hook raised")
146
+ end
105
147
  end
106
148
  ```
107
149
 
@@ -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, name:, **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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Attachable
5
+ module Subactions
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :_axnable_methods, default: {}
10
+ class_attribute :_axns, default: {}
11
+ end
12
+
13
+ class_methods do
14
+ def axnable_method(name, axn_klass = nil, **action_kwargs, &block)
15
+ raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
16
+
17
+ self._axnable_methods = _axnable_methods.merge(name => { axn_klass:, action_kwargs:, block: })
18
+
19
+ action_kwargs[:expose_return_as] ||= :value unless axn_klass
20
+ axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
21
+
22
+ define_singleton_method("#{name}_axn") do |**kwargs|
23
+ axn_klass.call(**kwargs)
24
+ end
25
+
26
+ define_singleton_method("#{name}!") do |**kwargs|
27
+ result = axn_klass.call!(**kwargs)
28
+ result.public_send(action_kwargs[:expose_return_as])
29
+ end
30
+ end
31
+
32
+ def axn(name, axn_klass = nil, **action_kwargs, &block)
33
+ raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
34
+
35
+ self._axns = _axns.merge(name => { axn_klass:, action_kwargs:, block: })
36
+
37
+ axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
38
+
39
+ define_singleton_method(name) do |**kwargs|
40
+ axn_klass.call(**kwargs)
41
+ end
42
+
43
+ # TODO: do we also need an instance-level version that auto-wraps in hoist_errors(label: name)?
44
+
45
+ define_singleton_method("#{name}!") do |**kwargs|
46
+ axn_klass.call!(**kwargs)
47
+ end
48
+
49
+ self._axns = _axns.merge(name => axn_klass)
50
+ end
51
+
52
+ def inherited(subclass)
53
+ super
54
+
55
+ return unless subclass.name.present? # TODO: not sure why..
56
+
57
+ # Need to redefine the axnable methods on the subclass to ensure they properly reference the subclass's
58
+ # helper method definitions and not the superclass's.
59
+ _axnable_methods.each do |name, config|
60
+ subclass.axnable_method(name, config[:axn_klass], **config[:action_kwargs], &config[:block])
61
+ end
62
+
63
+ _axns.each do |name, config|
64
+ subclass.axn(name, config[:axn_klass], **config[:action_kwargs], &config[:block])
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ 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
- msg = message_from_rescues
56
- return msg if msg.present?
58
+ interceptor = action.class._error_interceptor_for(exception:, action:)
59
+ msg = interceptor.message if interceptor
57
60
  end
58
61
 
59
- the_exception = @context.exception || (only_default ? Action::Failure.new(@context) : nil)
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?, :failure?, :exception, to: :context
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
@@ -34,9 +34,9 @@ module Action
34
34
  FieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
35
35
 
36
36
  module ClassMethods
37
- def expects(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
37
+ def expects(*fields, allow_blank: false, allow_nil: false, default: nil, preprocess: nil, sensitive: false,
38
38
  **validations)
39
- _parse_field_configs(*fields, allow_blank:, default:, preprocess:, sensitive:, **validations).tap do |configs|
39
+ _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations).tap do |configs|
40
40
  duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
41
41
  raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
42
42
 
@@ -45,8 +45,8 @@ module Action
45
45
  end
46
46
  end
47
47
 
48
- def exposes(*fields, allow_blank: false, default: nil, sensitive: false, **validations)
49
- _parse_field_configs(*fields, allow_blank:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
48
+ def exposes(*fields, allow_blank: false, allow_nil: false, default: nil, sensitive: false, **validations)
49
+ _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
50
50
  duplicated = external_field_configs.map(&:field) & configs.map(&:field)
51
51
  raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
52
52
 
@@ -57,11 +57,19 @@ module Action
57
57
 
58
58
  private
59
59
 
60
- def _parse_field_configs(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
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
+
66
+ def _parse_field_configs(*fields, allow_nil: false, 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
 
@@ -70,10 +78,13 @@ module Action
70
78
  v = { value: v } unless v.is_a?(Hash)
71
79
  { allow_blank: true }.merge(v)
72
80
  end
73
- elsif validations.key?(:boolean)
74
- validations[:presence] = false
81
+ elsif allow_nil
82
+ validations.transform_values! do |v|
83
+ v = { value: v } unless v.is_a?(Hash)
84
+ { allow_nil: true }.merge(v)
85
+ end
75
86
  else
76
- validations[:presence] = true unless validations.key?(:presence)
87
+ validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
77
88
  end
78
89
 
79
90
  fields.map { |field| FieldConfig.new(field:, validations:, default:, preprocess:, sensitive:) }
@@ -43,23 +43,21 @@ module Action
43
43
  end
44
44
  end
45
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
46
  class TypeValidator < ActiveModel::EachValidator
55
47
  def validate_each(record, attribute, value)
56
- return if value.blank? # Handled with a separate default presence validator
57
-
58
48
  # TODO: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
59
49
  types = options[:in].presence || Array(options[:with]).presence || Array(options[:value]).presence
60
50
 
51
+ return if value.blank? && !types.include?(:boolean) # Handled with a separate default presence validator
52
+
61
53
  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) }
54
+ record.errors.add attribute, (options[:message] || msg) unless types.any? do |type|
55
+ if type == :boolean
56
+ [true, false].include?(value)
57
+ else
58
+ value.is_a?(type)
59
+ end
60
+ end
63
61
  end
64
62
  end
65
63
  end
@@ -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
- warn "hoist_errors block swallowed an exception: #{e.message}"
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
- fail! result.error
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 :_error_rescues, default: []
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 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
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
- { matcher => message }.compact.merge(match_and_messages).each { |mam| self._error_rescues += [mam] }
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
@@ -44,10 +44,21 @@ module Action
44
44
 
45
45
  debug [
46
46
  "Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
47
- context_for_logging(:outbound).presence&.inspect,
47
+ _log_after_data_peak,
48
48
  ].compact.join(". Set: ")
49
49
  end
50
50
 
51
+ def _log_after_data_peak
52
+ return unless (data = context_for_logging(:outbound)).present?
53
+
54
+ max_length = 100
55
+ suffix = "...<truncated>...}"
56
+
57
+ data.inspect.tap do |str|
58
+ return str[0, max_length - suffix.length] + suffix if str.length > max_length
59
+ end
60
+ end
61
+
51
62
  def _call_and_return_outcome(hooked)
52
63
  hooked.call
53
64