axn 0.1.0.pre.alpha.2.5.3.1 → 0.1.0.pre.alpha.2.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/CHANGELOG.md +25 -1
  4. data/README.md +2 -11
  5. data/docs/reference/action-result.md +2 -0
  6. data/docs/reference/class.md +12 -4
  7. data/docs/reference/configuration.md +53 -20
  8. data/docs/reference/instance.md +2 -2
  9. data/docs/strategies/index.md +1 -1
  10. data/docs/usage/setup.md +1 -1
  11. data/docs/usage/writing.md +9 -9
  12. data/lib/action/attachable/steps.rb +16 -1
  13. data/lib/action/attachable.rb +3 -3
  14. data/lib/action/{core/configuration.rb → configuration.rb} +3 -4
  15. data/lib/action/context.rb +38 -0
  16. data/lib/action/core/automatic_logging.rb +93 -0
  17. data/lib/action/core/context/facade.rb +69 -0
  18. data/lib/action/core/context/facade_inspector.rb +63 -0
  19. data/lib/action/core/context/internal.rb +32 -0
  20. data/lib/action/core/contract.rb +167 -211
  21. data/lib/action/core/contract_for_subfields.rb +84 -82
  22. data/lib/action/core/contract_validation.rb +62 -0
  23. data/lib/action/core/flow/callbacks.rb +54 -0
  24. data/lib/action/core/flow/exception_execution.rb +79 -0
  25. data/lib/action/core/flow/messages.rb +61 -0
  26. data/lib/action/core/flow.rb +19 -0
  27. data/lib/action/core/hoist_errors.rb +42 -40
  28. data/lib/action/core/hooks.rb +123 -0
  29. data/lib/action/core/logging.rb +22 -20
  30. data/lib/action/core/timing.rb +40 -0
  31. data/lib/action/core/tracing.rb +17 -0
  32. data/lib/action/core/use_strategy.rb +19 -17
  33. data/lib/action/core/validation/fields.rb +2 -0
  34. data/lib/action/core.rb +100 -0
  35. data/lib/action/enqueueable/via_sidekiq.rb +2 -2
  36. data/lib/action/enqueueable.rb +1 -1
  37. data/lib/action/{core/exceptions.rb → exceptions.rb} +1 -19
  38. data/lib/action/result.rb +95 -0
  39. data/lib/axn/factory.rb +27 -9
  40. data/lib/axn/version.rb +1 -1
  41. data/lib/axn.rb +10 -47
  42. metadata +19 -21
  43. data/lib/action/core/context_facade.rb +0 -209
  44. data/lib/action/core/handle_exceptions.rb +0 -163
  45. data/lib/action/core/top_level_around_hook.rb +0 -108
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d67c51bf6cef09cdbfb8246769eefb7c9b1baa3f7d75168d599f898d66d00157
4
- data.tar.gz: 06d6b82b58b9a65bd1d8da9e68a8a640a5fcf0e1dafdbd5d756ea1d1682f7e95
3
+ metadata.gz: 30c00229f0062bdba5979d0e69de4ffddb6bece3f31413f83b7f7778d3075ef7
4
+ data.tar.gz: 92d944caf300ddfc9a134d34ae2cb73e6d235e0af9f7a10a3ee93065ae834690
5
5
  SHA512:
6
- metadata.gz: a656fe06eec316eb1c974586adec336afe6f09257da62f88789f467fe06f0f3677249fccb1aec7dd83c3328c1cc0ee505640e786b1892338ff0be71723b758c3
7
- data.tar.gz: 337a92138da80dcf5e00947b4cb00589d689e1dc6dae3340d91e76874e4a335feb3a1ef254c3e6220c6a0d271f49fe8f77f9deea5d731ed1caa3f3c4317c1b42
6
+ metadata.gz: 600b1572f324b8cbbaf2b5dd48108546db86e63e3c7d49a04e1eb9f0f2e83720eafdaabb94ccf0550ff1c0e63b3ec555950443eb5c064698dbc65cf97c2b1733
7
+ data.tar.gz: e9229f25cd10dcc54f1f7c5b83ff81b997d96263296818caaf2cfc245fde93ab24d7f0605700ea042c085ab7456f5fc1b20c2cc643324609bc0f4662ecbdcd21
data/.rubocop.yml CHANGED
@@ -42,8 +42,11 @@ Metrics/BlockLength:
42
42
  Metrics/ModuleLength:
43
43
  Enabled: false
44
44
 
45
+ Metrics/ClassLength:
46
+ Max: 110
47
+
45
48
  Metrics/MethodLength:
46
- Max: 60
49
+ Max: 70
47
50
 
48
51
  Metrics/PerceivedComplexity:
49
52
  Max: 16
data/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  ## Unreleased
4
4
  * N/A
5
5
 
6
+ ## 0.1.0-alpha.2.6.1
7
+ * [FEAT] Added `elapsed_time` and `outcome` methods to `Action::Result`
8
+ * `elapsed_time` returns execution time in milliseconds (Float)
9
+ * `outcome` returns execution outcome as symbol (`:success`, `:failure`, or `:exception`)
10
+ * [BREAKING] `emit_metrics` hook now receives the full `Action::Result` object instead of just the outcome
11
+ * Provides access to both outcome and elapsed time for richer metrics
12
+ * Example: `proc { |resource, result| TS::Metrics.histogram("action.duration", result.elapsed_time) }`
13
+ * [BREAKING] Replaced `Action.config.default_log_level` and `default_autolog_level` with simpler `log_level`
14
+ * [BREAKING] `autolog_level` method overrides with e.g. `auto_log :warn` or `auto_log false`
15
+ * [BREAKING] Direct access to exposed fields in callables no longer works -- `foo` becomes `result.foo`
16
+ * [BREAKING] Removed `success?` check on Action::Result (use `ok?` instead)
17
+ * [FEAT] Added callback and strategy support to Axn::Factory.build
18
+
19
+ ## 0.1.0-alpha.2.6
20
+ * Inline interactor code (no more dependency on unpublished forked branch to support inheritance)
21
+ * Refactor internals to clean implementation now that we have direct control
22
+ * [BREAKING] Replaced `Action.config.top_level_around_hook` with `.wrap_with_trace` and `.emit_metrics`
23
+ * [BREAKING] the order of hooks with inheritance has changed to more intuitively follow the natural pattern of setup (general → specific) and teardown (specific → general):
24
+ * **Before hooks**: Parent → Child (general setup first, then specific)
25
+ * **After hooks**: Child → Parent (specific cleanup first, then general)
26
+ * **Around hooks**: Parent wraps child (parent outside, child inside)
27
+ * Removed non-functional #rollback traces (use on_exception hook instead)
28
+ * Clean requires structure
29
+
6
30
  ## 0.1.0-alpha.2.5.3.1
7
31
  * Remove explicit 'require rspec' from `axn/testing/spec_helpers` (must already be loaded)
8
32
 
@@ -13,7 +37,7 @@
13
37
  ## 0.1.0-alpha.2.5.2
14
38
  * [BREAKING] Removing `EnqueueAllInBackground` + `EnqueueAllWorker` - better + simply solved at application level
15
39
  * [TEST] Expose spec helpers to consumers (add `require "axn/testing/spec_helpers"` to your `spec_helper.rb`)
16
- # [FEAT] Added ability to use custom Strategies (via e.g. `use :transaction`)
40
+ * [FEAT] Added ability to use custom Strategies (via e.g. `use :transaction`)
17
41
 
18
42
  ## 0.1.0-alpha.2.5.1.2
19
43
  * [BUGFIX] Subfield expectations: now support hashes with string keys (using with_indifferent_access)
data/README.md CHANGED
@@ -1,20 +1,11 @@
1
1
  # Axn -- [AHK-sin] (a.k.a. "Action")
2
2
 
3
- Just spinning this up -- not yet released (i.e. doc updates in flight).
3
+ Just spinning this up -- not yet publicly released, changes coming frequently.
4
4
 
5
5
  ## Installation & Usage
6
6
 
7
7
  See our [User Guide](https://teamshares.github.io/axn/) for details.
8
8
 
9
- ## [!!] Inheritance Support
10
-
11
- Out of the box Axn only supports a direct style (every action must `include Action`).
12
-
13
- If you want to support inheritance, you'll need to add this line to your `Gemfile` (we're layered over Interactor, and their released version doesn't yet support inheritance):
14
-
15
- gem "interactor", github: "kaspermeyer/interactor", branch: "fix-hook-inheritance"
16
-
17
-
18
9
  ## Development
19
10
 
20
11
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -27,4 +18,4 @@ See our [contribution guidelines](CONTRIBUTING.md) for more information.
27
18
 
28
19
  ## Thank You
29
20
 
30
- A very special thank you to [Collective Idea](https://collectiveidea.com/)'s fantastic [Interactor](https://github.com/collectiveidea/interactor?tab=readme-ov-file#interactor) library, which [we](https://www.teamshares.com/) used successfully for a number of years and which still forms the basis of this library today.
21
+ A very special thank you to [Collective Idea](https://collectiveidea.com/)'s fantastic [Interactor](https://github.com/collectiveidea/interactor?tab=readme-ov-file#interactor) library, which [we](https://www.teamshares.com/) used successfully for a number of years and which we used to scaffold early versions of this library.
@@ -9,6 +9,8 @@ Every `call` invocation on an Action will return an `Action::Result` instance, w
9
9
  | `success` | User-facing success message (string), if `ok?` (else nil)
10
10
  | `message` | User-facing message (string), always defined (`ok? ? success : error`)
11
11
  | `exception` | If not `ok?` because an exception was swallowed, will be set to the swallowed exception (note: rarely used outside development; prefer to let the library automatically handle exception handling for you)
12
+ | `outcome` | The execution outcome as a symbol (`:success`, `:failure`, or `:exception`)
13
+ | `elapsed_time` | Execution time in milliseconds (Float)
12
14
  | any `expose`d values | guaranteed to be set if `ok?` (since they have outgoing presence validations by default; any missing would have failed the action)
13
15
 
14
16
  NOTE: `success` and `error` (and so implicitly `message`) can be configured per-action via [the `messages` declaration](/reference/class#messages).
@@ -89,10 +89,16 @@ will succeed if given _either_ an actual Date object _or_ a string that Date.par
89
89
 
90
90
  The `messages` declaration allows you to customize the `error` and `success` messages on the returned result.
91
91
 
92
- Accepts `error` and/or `success` keys. Values can be a string (returned directly) or a callable (evaluated in the action's context, so can access instance methods). If `error` is provided with a callable that expects a positional argument, the exception that was raised will be passed in as that value.
92
+ Accepts `error` and/or `success` keys. Values can be a string (returned directly) or a callable (evaluated in the action's context, so can access instance methods and variables). If `error` is provided with a callable that expects a positional argument, the exception that was raised will be passed in as that value.
93
+
94
+ In callables, you can access:
95
+ - **Input data**: Use field names directly (e.g., `name`)
96
+ - **Output data**: Use `result.field` pattern (e.g., `result.greeting`)
97
+ - **Instance methods and variables**: Direct access
93
98
 
94
99
  ```ruby
95
- messages success: "All good!", error: ->(e) { "Bad news: #{e.message}" }
100
+ messages success: -> { "Hello #{name}, your greeting: #{result.greeting}" },
101
+ error: ->(e) { "Bad news: #{e.message}" }
96
102
  ```
97
103
 
98
104
  ## `error_from` and `rescues`
@@ -101,15 +107,17 @@ While `.messages` sets the _default_ error/success messages and is more commonly
101
107
 
102
108
  `error_from` and `rescues` both register a matcher (exception class, exception class name (string), or callable) and a message to use if the matcher succeeds. They act exactly the same, except if a matcher registered with `rescues` succeeds, the exception _will not_ trigger the configured exception handlers (global or specific to this class).
103
109
 
110
+ Callable matchers and messages follow the same data access patterns as other callables: input fields directly, output fields via `result.field`, instance variables, and methods.
111
+
104
112
  ```ruby
105
113
  messages error: "bad"
106
114
 
107
115
  # Note this will NOT trigger Action.config.on_exception
108
116
  rescues ActiveRecord::InvalidRecord => "Invalid params provided"
109
117
 
110
- # These WILL trigger error handler (second demonstrates callable matcher AND message)
118
+ # These WILL trigger error handler (callable matcher + message with data access)
111
119
  error_from ArgumentError, ->(e) { "Argument error: #{e.message}" }
112
- error_from -> { name == "bad" }, -> { "was given bad name: #{name}" }
120
+ error_from -> { name == "bad" }, -> { "Bad input #{name}, result: #{result.status}" }
113
121
  ```
114
122
 
115
123
  ## Callbacks
@@ -4,18 +4,16 @@ Somewhere at boot (e.g. `config/initializers/actions.rb` in Rails), you can call
4
4
 
5
5
 
6
6
  ```ruby
7
- Action.configure do |c|
8
- c.on_exception = ...
9
-
10
- c.top_level_around_hook = ...
11
-
12
- c.additional_includes = []
13
-
14
- c.default_log_level = :info
15
- c.default_autolog_level = :debug
16
-
17
- c.logger = ...
7
+ Action.configure do |c|
8
+ c.log_level = :info
9
+ c.logger = ...
10
+ c.on_exception = proc do |e, action:, context:|
11
+ message = "[#{action.class.name}] Failing due to #{e.class.name}: #{e.message}"
12
+
13
+ Rails.logger.warn(message)
14
+ Honeybadger.notify(message, context: { axn_context: context })
18
15
  end
16
+ end
19
17
  ```
20
18
 
21
19
  ## `on_exception`
@@ -63,24 +61,36 @@ A couple notes:
63
61
 
64
62
  If you're using an APM provider, observability can be greatly enhanced by adding automatic _tracing_ of Action calls and/or emitting count metrics after each call completes.
65
63
 
64
+ ### Tracing and Metrics
65
+
66
+ The framework provides two distinct hooks for observability:
67
+
68
+ - **`wrap_with_trace`**: An around hook that wraps the entire action execution. You MUST call the provided block to execute the action.
69
+ - **`emit_metrics`**: A post-execution hook that receives the action result. Do NOT call any blocks.
70
+
66
71
  For example, to wire up Datadog:
67
72
 
68
73
  ```ruby
69
74
  Action.configure do |c|
70
- c.top_level_around_hook = proc do |resource, &action|
75
+ c.wrap_with_trace = proc do |resource, &action|
71
76
  Datadog::Tracing.trace("Action", resource:) do
72
- (outcome, _exception) = action.call
73
-
74
- TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome:, resource: })
77
+ action.call
75
78
  end
76
79
  end
80
+
81
+ c.emit_metrics = proc do |resource, result|
82
+ TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome: result.outcome, resource: })
83
+ TS::Metrics.histogram("action.duration", result.elapsed_time, tags: { resource: })
84
+ end
77
85
  end
78
86
  ```
79
87
 
80
88
  A couple notes:
81
89
 
82
90
  * `Datadog::Tracing` is provided by [the datadog gem](https://rubygems.org/gems/datadog)
83
- * `TS::Metrics` is a custom implementation to set a Datadog count metric, but the relevant part to note is that outcome (`success`, `failure`, `exception`) of the action is reported so you can easily track e.g. success rates per action.
91
+ * `TS::Metrics` is a custom implementation to set a Datadog count metric, but the relevant part to note is that the result object provides access to the outcome (`success`, `failure`, `exception`) and elapsed time of the action.
92
+ * The `wrap_with_trace` hook is an around hook - you must call the provided block to execute the action
93
+ * The `emit_metrics` hook is called after execution with the result - do not call any blocks
84
94
 
85
95
 
86
96
  ## `logger`
@@ -101,11 +111,11 @@ For example:
101
111
 
102
112
  For a practical example of this in practice, see [our 'memoization' recipe](/recipes/memoization).
103
113
 
104
- ## `default_log_level`
114
+ ## `log_level`
105
115
 
106
- Sets the log level used when you call `log "Some message"` in your Action. Note this is read via a `default_log_level` class method, so you can easily use inheritance to support different log levels for different sets of actions.
116
+ Sets the log level used when you call `log "Some message"` in your Action. Note this is read via a `log_level` class method, so you can easily use inheritance to support different log levels for different sets of actions.
107
117
 
108
- ## `default_autolog_level`
118
+ ## Automatic Logging
109
119
 
110
120
  By default, every `action.call` will emit log lines when it is called and after it completes:
111
121
 
@@ -114,4 +124,27 @@ By default, every `action.call` will emit log lines when it is called and after
114
124
  [YourCustomAction] Execution completed (with outcome: success) in 0.957 milliseconds
115
125
  ```
116
126
 
117
- You can change the default _auto_-log level separately from the log level used for your explicit `log` calls (just like above, via Action.config or a `default_autolog_level` class method).
127
+ Automatic logging will log at `Action.config.log_level` by default, but can be overridden or disabled using the declarative `auto_log` method:
128
+
129
+ ```ruby
130
+ # Set default for all actions (affects both explicit logging and automatic logging)
131
+ Action.configure do |c|
132
+ c.log_level = :debug
133
+ end
134
+
135
+ # Override for specific actions
136
+ class MyAction
137
+ auto_log :warn # Use warn level for this action
138
+ end
139
+
140
+ class SilentAction
141
+ auto_log false # Disable automatic logging for this action
142
+ end
143
+
144
+ # Use default level (no auto_log call needed)
145
+ class DefaultAction
146
+ # Uses Action.config.log_level
147
+ end
148
+ ```
149
+
150
+ The `auto_log` method supports inheritance, so subclasses will inherit the setting from their parent class unless explicitly overridden.
@@ -12,7 +12,7 @@ Primarily used for its side effects, but it does return a Hash with the key/valu
12
12
 
13
13
  ## `#fail!`
14
14
 
15
- Called with a string, it immediately halts execution (including triggering any [rollback handler](/reference/class#rollback) you have defined) and sets `result.error` to the provided string.
15
+ Called with a string, it immediately halts execution and sets `result.error` to the provided string.
16
16
 
17
17
  ## `#log`
18
18
 
@@ -31,7 +31,7 @@ A few details:
31
31
  * An explicit `fail!` call _will_ still fail the action
32
32
  * Any exceptions swallowed _will_ still be reported via the `on_exception` handler
33
33
 
34
- This is primarily useful in an after block, e.g. trigger notifications after an action has been taken. If the notification fails to send you DO want to log the failure somewhere to investigate, but since the core action has already been taken often you do _not_ want to fail and roll back.
34
+ This is primarily useful in an after block, e.g. trigger notifications after an action has been taken. If the notification fails to send you DO want to log the failure somewhere to investigate, but since the core action has already been taken often you do _not_ want to fail.
35
35
 
36
36
  Example:
37
37
 
@@ -155,7 +155,7 @@ Action::Strategies.register(:retry, RetryStrategy)
155
155
 
156
156
  ### Example: Complete Custom Strategy
157
157
 
158
- Here's a complete example of a custom strategy that adds performance monitoring:
158
+ Here's a complete example of a custom strategy that adds performance monitoring (note Axn already logs elapsed time, this is just a toy example):
159
159
 
160
160
  ```ruby
161
161
  module PerformanceMonitoringStrategy
data/docs/usage/setup.md CHANGED
@@ -19,6 +19,6 @@ By default any swallowed errors are noted in the logs, but it's _highly recommen
19
19
 
20
20
  ### Metrics / Tracing
21
21
 
22
- If you're using an APM provider, observability can be greatly enhanced by [configuring a `top_level_around_hook`](/reference/configuration#top-level-around-hook).
22
+ If you're using an APM provider, observability can be greatly enhanced by [configuring tracing and metrics hooks](/reference/configuration#tracing-and-metrics).
23
23
 
24
24
 
@@ -79,7 +79,7 @@ class Foo
79
79
  expects :name, type: String
80
80
  exposes :meaning_of_life
81
81
 
82
- messages success: -> { "Revealed the secret of life to #{name}" }, # [!code focus:2]
82
+ messages success: -> { "Revealed to #{name}: #{result.meaning_of_life}" }, # [!code focus:2]
83
83
  error: ->(e) { "No secret of life for you: #{e.message}" }
84
84
 
85
85
  def call
@@ -104,18 +104,11 @@ Foo.call(name: "Adams").meaning_of_life # => "Hello Adams, the meaning of life i
104
104
 
105
105
  In addition to `#call`, there are a few additional pieces to be aware of:
106
106
 
107
- <!-- ### `#rollback`
108
- *** TODO: rollback actually only applies to rolling back *completed* steps of a multi-step Axn chain. Do not document for now -- need to decide if adding a trigger-when-axn-itself-fails rollback path. ***
109
107
 
110
- ::: danger ALPHA
111
- * ⚠️ `#rollback` is _expected_ to be added shortly, but is not yet functional!
112
- :::
113
-
114
- If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails. -->
115
108
 
116
109
  ### Hooks
117
110
 
118
- `before` and `after` hooks are supported. They can receive a block directly, or the symbol name of a local method.
111
+ `before`, `after`, and `around` hooks are supported. They can receive a block directly, or the symbol name of a local method.
119
112
 
120
113
  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 `result.ok?` be false even though `call` completed successfully).
121
114
 
@@ -150,6 +143,13 @@ in call
150
143
  after hook
151
144
  ```
152
145
 
146
+ **Hook Ordering with Inheritance:**
147
+ - **Around hooks**: Parent wraps child (parent outside, child inside)
148
+ - **Before hooks**: Parent → Child (general setup first, then specific)
149
+ - **After hooks**: Child → Parent (specific cleanup first, then general)
150
+
151
+ This follows the natural pattern of setup (general → specific) and teardown (specific → general).
152
+
153
153
  ### Callbacks
154
154
 
155
155
  A number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails. See the [Class Interface docs](/reference/class#callbacks) for details.
@@ -38,10 +38,25 @@ module Action
38
38
  step = Entry.new(label: "Step #{idx + 1}", axn: step) if step.is_a?(Class)
39
39
 
40
40
  hoist_errors(prefix: "#{step.label} step") do
41
- step.axn.call(@context)
41
+ step.axn.call(**merged_context_data).tap do |step_result|
42
+ merge_step_exposures!(step_result)
43
+ end
42
44
  end
43
45
  end
44
46
  end
47
+
48
+ private
49
+
50
+ def merged_context_data
51
+ @__context.__combined_data
52
+ end
53
+
54
+ # Each step can expect the data exposed from the previous steps
55
+ def merge_step_exposures!(step_result)
56
+ step_result.declared_fields.each do |field|
57
+ @__context.exposed_data[field] = step_result.public_send(field)
58
+ end
59
+ end
45
60
  end
46
61
  end
47
62
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "attachable/base"
4
- require_relative "attachable/steps"
5
- require_relative "attachable/subactions"
3
+ require "action/attachable/base"
4
+ require "action/attachable/steps"
5
+ require "action/attachable/subactions"
6
6
 
7
7
  module Action
8
8
  module Attachable
@@ -2,11 +2,10 @@
2
2
 
3
3
  module Action
4
4
  class Configuration
5
- attr_accessor :top_level_around_hook
6
- attr_writer :logger, :env, :on_exception, :additional_includes, :default_log_level, :default_autolog_level
5
+ attr_accessor :wrap_with_trace, :emit_metrics
6
+ attr_writer :logger, :env, :on_exception, :additional_includes, :log_level
7
7
 
8
- def default_log_level = @default_log_level ||= :info
9
- def default_autolog_level = @default_autolog_level ||= :info
8
+ def log_level = @log_level ||= :info
10
9
 
11
10
  def additional_includes = @additional_includes ||= []
12
11
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ class Context
5
+ attr_accessor :provided_data, :exposed_data
6
+
7
+ def initialize(**provided_data)
8
+ @provided_data = provided_data
9
+ @exposed_data = {}
10
+
11
+ # Framework-managed fields
12
+ @failure = false
13
+ @exception = nil
14
+ @error_from_user = nil
15
+ @error_prefix = nil
16
+ @elapsed_time = nil
17
+ end
18
+
19
+ def fail!(message = nil)
20
+ @error_from_user = message if message.present?
21
+ raise Action::Failure, message
22
+ end
23
+
24
+ # INTERNAL: base for further filtering (for logging) or providing user with usage hints
25
+ def __combined_data = @provided_data.merge(@exposed_data)
26
+
27
+ # Framework state methods
28
+ def ok? = !@failure
29
+ def failed? = @failure || false
30
+
31
+ # Framework field accessors
32
+ attr_accessor :exception, :error_from_user, :error_prefix, :elapsed_time
33
+
34
+ # Internal failure state setter (for framework use)
35
+ attr_writer :failure
36
+ private :failure=
37
+ end
38
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module AutomaticLogging
6
+ def self.included(base)
7
+ base.class_eval do
8
+ extend ClassMethods
9
+ include InstanceMethods
10
+
11
+ # Single class_attribute - nil means disabled, any level means enabled
12
+ class_attribute :auto_log_level, default: Action.config.log_level
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ def auto_log(level)
18
+ self.auto_log_level = level.presence
19
+ end
20
+ end
21
+
22
+ module InstanceMethods
23
+ private
24
+
25
+ def _with_logging
26
+ _log_before if self.class.auto_log_level
27
+ yield
28
+ ensure
29
+ _log_after if self.class.auto_log_level
30
+ end
31
+
32
+ def _log_before
33
+ level = self.class.auto_log_level
34
+ return unless level
35
+
36
+ self.class.public_send(
37
+ level,
38
+ [
39
+ "About to execute",
40
+ _log_context(:inbound),
41
+ ].compact.join(" with: "),
42
+ before: Action.config.env.production? ? nil : "\n------\n",
43
+ )
44
+ rescue StandardError => e
45
+ Axn::Util.piping_error("logging before hook", action: self, exception: e)
46
+ end
47
+
48
+ def _log_after
49
+ level = self.class.auto_log_level
50
+ return unless level
51
+
52
+ self.class.public_send(
53
+ level,
54
+ [
55
+ "Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
56
+ _log_context(:outbound),
57
+ ].compact.join(". Set: "),
58
+ after: Action.config.env.production? ? nil : "\n------\n",
59
+ )
60
+ rescue StandardError => e
61
+ Axn::Util.piping_error("logging after hook", action: self, exception: e)
62
+ end
63
+
64
+ def _log_context(direction)
65
+ data = context_for_logging(direction)
66
+ return unless data.present?
67
+
68
+ max_length = 150
69
+ suffix = "…<truncated>…"
70
+
71
+ _log_object(data).tap do |str|
72
+ return str[0, max_length - suffix.length] + suffix if str.length > max_length
73
+ end
74
+ end
75
+
76
+ def _log_object(data)
77
+ case data
78
+ when Hash
79
+ # NOTE: slightly more manual in order to avoid quotes around ActiveRecord objects' <Class#id> formatting
80
+ "{#{data.map { |k, v| "#{k}: #{_log_object(v)}" }.join(", ")}}"
81
+ when Array
82
+ data.map { |v| _log_object(v) }
83
+ else
84
+ return data.to_unsafe_h if defined?(ActionController::Parameters) && data.is_a?(ActionController::Parameters)
85
+ return "<#{data.class.name}##{data.to_param.presence || "unpersisted"}>" if defined?(ActiveRecord::Base) && data.is_a?(ActiveRecord::Base)
86
+
87
+ data.inspect
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/parameter_filter"
4
+
5
+ module Action
6
+ class ContextFacade
7
+ def initialize(action:, context:, declared_fields:, implicitly_allowed_fields: nil)
8
+ if self.class.name == "Action::ContextFacade" # rubocop:disable Style/ClassEqualityComparison
9
+ raise "Action::ContextFacade is an abstract class and should not be instantiated directly"
10
+ end
11
+
12
+ @context = context
13
+ @action = action
14
+ @declared_fields = declared_fields
15
+
16
+ (@declared_fields + Array(implicitly_allowed_fields)).each do |field|
17
+ singleton_class.define_method(field) do
18
+ context_data_source[field]
19
+ end
20
+ end
21
+ end
22
+
23
+ attr_reader :declared_fields
24
+
25
+ def inspect = ContextFacadeInspector.new(facade: self, action:, context:).call
26
+
27
+ def fail!(...)
28
+ raise Action::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :action, :context
34
+
35
+ def action_name = @action.class.name.presence || "The action"
36
+
37
+ def context_data_source = raise NotImplementedError
38
+
39
+ def determine_error_message(only_default: false)
40
+ return @context.error_from_user if @context.error_from_user.present?
41
+
42
+ # We need an exception for interceptors, and also in case the messages.error callable expects an argument
43
+ exception = @context.exception || Action::Failure.new
44
+
45
+ msg = action._error_msg
46
+
47
+ unless only_default
48
+ interceptor = action.class._error_interceptor_for(exception:, action:)
49
+ msg = interceptor.message if interceptor
50
+ end
51
+
52
+ stringified(msg, exception:).presence || "Something went wrong"
53
+ end
54
+
55
+ # Allow for callable OR string messages
56
+ def stringified(msg, exception: nil)
57
+ return msg.presence unless msg.respond_to?(:call)
58
+
59
+ # The error message callable can take the exception as an argument
60
+ if exception && msg.arity == 1
61
+ action.instance_exec(exception, &msg)
62
+ else
63
+ action.instance_exec(&msg)
64
+ end
65
+ rescue StandardError => e
66
+ Axn::Util.piping_error("determining message callable", action:, exception: e)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ class ContextFacadeInspector
5
+ def initialize(action:, facade:, context:)
6
+ @action = action
7
+ @facade = facade
8
+ @context = context
9
+ end
10
+
11
+ def call
12
+ str = [status, visible_fields].compact_blank.join(" ")
13
+
14
+ "#<#{class_name} #{str}>"
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :action, :facade, :context
20
+
21
+ def status
22
+ return unless facade.is_a?(Action::Result)
23
+
24
+ return "[OK]" if context.ok?
25
+ unless context.exception
26
+ return context.error_from_user.present? ? "[failed with '#{context.error_from_user}']" : "[failed]"
27
+ end
28
+
29
+ %([failed with #{context.exception.class.name}: '#{context.exception.message}'])
30
+ end
31
+
32
+ def visible_fields
33
+ declared_fields.map do |field|
34
+ value = facade.public_send(field)
35
+
36
+ "#{field}: #{format_for_inspect(field, value)}"
37
+ end.join(", ")
38
+ end
39
+
40
+ def class_name = facade.class.name
41
+ def declared_fields = facade.send(:declared_fields)
42
+
43
+ def format_for_inspect(field, value)
44
+ return value.inspect if value.nil?
45
+
46
+ # Initially based on https://github.com/rails/rails/blob/800976975253be2912d09a80757ee70a2bb1e984/activerecord/lib/active_record/attribute_methods.rb#L527
47
+ inspected_value = if value.is_a?(String) && value.length > 50
48
+ "#{value[0, 50]}...".inspect
49
+ elsif value.is_a?(Date) || value.is_a?(Time)
50
+ %("#{value.to_fs(:inspect)}")
51
+ elsif defined?(::ActiveRecord::Relation) && value.instance_of?(::ActiveRecord::Relation)
52
+ # Avoid hydrating full AR relation (i.e. avoid loading records just to report an error)
53
+ "#{value.name}::ActiveRecord_Relation"
54
+ else
55
+ value.inspect
56
+ end
57
+
58
+ inspection_filter.filter_param(field, inspected_value)
59
+ end
60
+
61
+ def inspection_filter = action.send(:inspection_filter)
62
+ end
63
+ end