axn 0.1.0.pre.alpha.2.6 → 0.1.0.pre.alpha.2.7

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/CHANGELOG.md +23 -2
  4. data/docs/reference/action-result.md +2 -0
  5. data/docs/reference/class.md +140 -19
  6. data/docs/reference/configuration.md +42 -20
  7. data/docs/usage/writing.md +1 -1
  8. data/lib/action/attachable/steps.rb +16 -1
  9. data/lib/action/configuration.rb +2 -3
  10. data/lib/action/context.rb +28 -18
  11. data/lib/action/core/automatic_logging.rb +24 -8
  12. data/lib/action/core/context/facade.rb +39 -0
  13. data/lib/action/core/context/facade_inspector.rb +63 -0
  14. data/lib/action/core/context/internal.rb +38 -0
  15. data/lib/action/core/contract.rb +25 -8
  16. data/lib/action/core/contract_for_subfields.rb +1 -1
  17. data/lib/action/core/contract_validation.rb +15 -4
  18. data/lib/action/core/flow/callbacks.rb +54 -0
  19. data/lib/action/core/flow/exception_execution.rb +65 -0
  20. data/lib/action/core/flow/handlers/base_handler.rb +32 -0
  21. data/lib/action/core/flow/handlers/callback_handler.rb +21 -0
  22. data/lib/action/core/flow/handlers/invoker.rb +73 -0
  23. data/lib/action/core/flow/handlers/matcher.rb +85 -0
  24. data/lib/action/core/flow/handlers/message_handler.rb +27 -0
  25. data/lib/action/core/flow/handlers/registry.rb +40 -0
  26. data/lib/action/core/flow/handlers.rb +17 -0
  27. data/lib/action/core/flow/messages.rb +75 -0
  28. data/lib/action/core/flow.rb +19 -0
  29. data/lib/action/core/hoist_errors.rb +2 -2
  30. data/lib/action/core/hooks.rb +15 -15
  31. data/lib/action/core/logging.rb +2 -2
  32. data/lib/action/core/timing.rb +18 -0
  33. data/lib/action/core/tracing.rb +17 -0
  34. data/lib/action/core/validation/fields.rb +2 -0
  35. data/lib/action/core.rb +25 -78
  36. data/lib/action/enqueueable/via_sidekiq.rb +2 -2
  37. data/lib/action/result.rb +114 -0
  38. data/lib/axn/factory.rb +45 -7
  39. data/lib/axn/version.rb +1 -1
  40. metadata +18 -5
  41. data/lib/action/core/context_facade.rb +0 -209
  42. data/lib/action/core/event_handlers.rb +0 -62
  43. data/lib/action/core/handle_exceptions.rb +0 -143
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49a6ab763d534efa878ff7f0d833beefeaae604bddb1c2e89090788a1e7df1a0
4
- data.tar.gz: 828cfbd5ad2b9f753bf938bdb84201edbb406b367c159d4b1f437054a3bc213f
3
+ metadata.gz: b128603df1eec45048f8656f01508a2e9a706e9acd92ce74813cac0bb038f550
4
+ data.tar.gz: cfc86ffaadab4cc758546eab39992de2d7f3c89547007a2d88643b303d0edd4b
5
5
  SHA512:
6
- metadata.gz: 8be59eb695a3df836513fb477b2338bb539da209821d17ab6502314ea57e62204e9a0e5ae49ec579db5d5c8e99ca6de57596acf577ee2027d8410e23a5013f15
7
- data.tar.gz: 1a559aff5de117890928f67a5e0adefe1f2615c24f15ac6357dff0c74bd3f9a9470110618228a30aaba35ae489844cca4efd1f701aba0f1805c18b5c71edd7a9
6
+ metadata.gz: 2767343c37a0612a446482b52320d2ab85009af9e4094fffa0be31577bdf4a67e228f08307e31c0d182dbd3b944881f10af3c7e80d19a7d6c6f6f08a33294417
7
+ data.tar.gz: fb5d3942ecbeb2e52f4f4376b5d1b90db696470704d39d1b2db55e6880956073f12f0213a13ad8a564ee2048ad76350bebb811ff90db125bf9724d2d31d3167a
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
@@ -1,7 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
4
- * N/A
3
+ ## 0.1.0-alpha.2.7
4
+ * [BREAKING] Replaced `messages` declaration with separate `success` and `error` calls
5
+ * [BREAKING] Removed `rescues` method (use `error_from` for custom error messages; all exceptions now report to `on_exception` handlers)
6
+ * [BREAKING] Replaced `error_from` with an optional `if:` argument on `error`
7
+ * [FEAT] Implemented conditional success message filtering as well
8
+ * [FEAT] Added block support for `error` and `success`
9
+ * [FEAT] `if:` now supports symbol predicates referencing instance methods (arity 0, 1, or keyword `exception:`). If the method accepts `exception:` it is passed as a keyword; else if it accepts one positional arg, it is passed positionally; otherwise it is called with no args. If the method is missing, the symbol falls back to constant lookup (e.g., `:ArgumentError`).
10
+ * [FEAT] `success`/`error` and callbacks now accept symbol method names (e.g., `success :local_method`). Handlers can receive the exception via keyword (`exception:`) or single positional argument; otherwise they are called with no args.
11
+ * [BREAKING] Updated callback methods (`on_success`, `on_error`, `on_failure`, `on_exception`) to use consistent `if:` interface (matching messages)
12
+ * [FEAT] Added `unless:` support to both `success`/`error` messages and callbacks (`on_success`, `on_error`, `on_failure`, `on_exception`)
13
+
14
+ ## 0.1.0-alpha.2.6.1
15
+ * [FEAT] Added `elapsed_time` and `outcome` methods to `Action::Result`
16
+ * `elapsed_time` returns execution time in milliseconds (Float)
17
+ * `outcome` returns execution outcome as symbol (`:success`, `:failure`, or `:exception`)
18
+ * [BREAKING] `emit_metrics` hook now receives the full `Action::Result` object instead of just the outcome
19
+ * Provides access to both outcome and elapsed time for richer metrics
20
+ * Example: `proc { |resource, result| TS::Metrics.histogram("action.duration", result.elapsed_time) }`
21
+ * [BREAKING] Replaced `Action.config.default_log_level` and `default_autolog_level` with simpler `log_level`
22
+ * [BREAKING] `autolog_level` method overrides with e.g. `auto_log :warn` or `auto_log false`
23
+ * [BREAKING] Direct access to exposed fields in callables no longer works -- `foo` becomes `result.foo`
24
+ * [BREAKING] Removed `success?` check on Action::Result (use `ok?` instead)
25
+ * [FEAT] Added callback and strategy support to Axn::Factory.build
5
26
 
6
27
  ## 0.1.0-alpha.2.6
7
28
  * Inline interactor code (no more dependency on unpublished forked branch to support inheritance)
@@ -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).
@@ -85,40 +85,148 @@ expects :date, type: Date, preprocess: ->(d) { d.is_a?(Date) ? d : Date.parse(d)
85
85
 
86
86
  will succeed if given _either_ an actual Date object _or_ a string that Date.parse can convert into one. If the preprocess callable raises an exception, that'll be swallowed and the action failed.
87
87
 
88
- ## `.messages`
88
+ ## `.success` and `.error`
89
89
 
90
- The `messages` declaration allows you to customize the `error` and `success` messages on the returned result.
90
+ The `success` and `error` declarations allow 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
+ Both methods accept a string (returned directly), a symbol (resolved as a local instance method on the action), or a block (evaluated in the action's context, so can access instance methods and variables).
93
+
94
+ When an exception is available (e.g., during `error`), handlers can receive it in either of two equivalent ways:
95
+ - Keyword form: accept `exception:` and it will be passed as a keyword
96
+ - Positional form: if the handler accepts a single positional argument, it will be passed positionally
97
+
98
+ This applies to both blocks and symbol-backed instance methods. Choose the style that best fits your codebase (clarity vs concision).
99
+
100
+ In callables and symbol-backed methods, you can access:
101
+ - **Input data**: Use field names directly (e.g., `name`)
102
+ - **Output data**: Use `result.field` pattern (e.g., `result.greeting`)
103
+ - **Instance methods and variables**: Direct access
93
104
 
94
105
  ```ruby
95
- messages success: "All good!", error: ->(e) { "Bad news: #{e.message}" }
106
+ success { "Hello #{name}, your greeting: #{result.greeting}" }
107
+ error { |e| "Bad news: #{e.message}" }
108
+ error { |exception:| "Bad news: #{exception.message}" }
109
+
110
+ # Using symbol method names
111
+ success :build_success_message
112
+ error :build_error_message
113
+
114
+ def build_success_message
115
+ "Hello #{name}, your greeting: #{result.greeting}"
116
+ end
117
+
118
+ def build_error_message(e)
119
+ "Bad news: #{e.message}"
120
+ end
121
+
122
+ def build_error_message(exception:)
123
+ "Bad news: #{exception.message}"
124
+ end
96
125
  ```
97
126
 
98
- ## `error_from` and `rescues`
127
+ ## Conditional messages
128
+
129
+ While `.error` and `.success` set the default messages, you can register conditional messages using an optional `if:` or `unless:` matcher. The matcher can be:
99
130
 
100
- While `.messages` sets the _default_ error/success messages and is more commonly used, there are times when you want specific error messages for specific failure cases.
131
+ - an exception class (e.g., `ArgumentError`)
132
+ - a class name string (e.g., `"Action::InboundValidationError"`)
133
+ - a symbol referencing a local instance method predicate (arity 0 or 1, or keyword `exception:`), e.g. `:bad_input?`
134
+ - a callable (arity 0 or 1, or keyword `exception:`)
101
135
 
102
- `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).
136
+ Symbols are resolved as methods on the action instance. If the method accepts `exception:` it will be passed as a keyword; otherwise, if it accepts one positional argument, the raised exception is passed positionally; otherwise it is called with no arguments. If the action does not respond to the symbol, we fall back to constant lookup (e.g., `if: :ArgumentError` behaves like `if: ArgumentError`). Symbols are also supported for the message itself (e.g., `success :method_name`), resolved via the same rules.
103
137
 
104
138
  ```ruby
105
- messages error: "bad"
139
+ error "bad"
106
140
 
107
- # Note this will NOT trigger Action.config.on_exception
108
- rescues ActiveRecord::InvalidRecord => "Invalid params provided"
141
+ # Custom message with exception class matcher
142
+ error "Invalid params provided", if: ActiveRecord::InvalidRecord
109
143
 
110
- # These WILL trigger error handler (second demonstrates callable matcher AND message)
111
- error_from ArgumentError, ->(e) { "Argument error: #{e.message}" }
112
- error_from -> { name == "bad" }, -> { "was given bad name: #{name}" }
144
+ # Custom message with callable matcher and message
145
+ error(if: ArgumentError) { |e| "Argument error: #{e.message}" }
146
+ error(if: -> { name == "bad" }) { "Bad input #{name}, result: #{result.status}" }
147
+
148
+ # Custom message with symbol predicate (arity 0)
149
+ error "Transient error, please retry", if: :transient_error?
150
+
151
+ def transient_error?
152
+ # local decision based on inputs/outputs
153
+ name == "temporary"
154
+ end
155
+
156
+ # Symbol predicate (arity 1), receives the exception
157
+ error(if: :argument_error?) { |e| "Bad argument: #{e.message}" }
158
+
159
+ def argument_error?(e)
160
+ e.is_a?(ArgumentError)
161
+ end
162
+
163
+ # Symbol predicate (keyword), receives the exception via keyword
164
+ error(if: :argument_error_kw?) { |exception:| "Bad argument: #{exception.message}" }
165
+
166
+ def argument_error_kw?(exception:)
167
+ exception.is_a?(ArgumentError)
168
+ end
169
+
170
+ # Lambda predicate with keyword
171
+ error "AE", if: ->(exception:) { exception.is_a?(ArgumentError) }
172
+
173
+ # Using unless: for inverse logic
174
+ error "Custom error", unless: :should_skip?
175
+
176
+ def should_skip?
177
+ # local decision based on inputs/outputs
178
+ name == "temporary"
179
+ end
180
+
181
+ ::: warning
182
+ You cannot use both `if:` and `unless:` for the same message - this will raise an `ArgumentError`.
183
+ :::
184
+
185
+ ### Message ordering and inheritance
186
+
187
+ Messages are evaluated in **last-defined-first** order, meaning the most recently defined message that matches its conditions will be used. This applies to both success and error messages:
188
+
189
+ ```ruby
190
+ class ParentAction
191
+ include Action
192
+
193
+ success "Parent success message"
194
+ error "Parent error message"
195
+ end
196
+
197
+ class ChildAction < ParentAction
198
+ success "Child success message" # This will be used when action succeeds
199
+ error "Child error message" # This will be used when action fails
200
+ end
201
+ ```
202
+
203
+ Within a single class, later definitions override earlier ones:
204
+
205
+ ```ruby
206
+ class MyAction
207
+ include Action
208
+
209
+ success "First success message" # Ignored
210
+ success "Second success message" # Ignored
211
+ success "Final success message" # This will be used
212
+
213
+ error "First error message" # Ignored
214
+ error "Second error message" # Ignored
215
+ error "Final error message" # This will be used
216
+ end
217
+ ```
218
+
219
+ When using conditional messages, the system evaluates handlers in the order defined above until it finds one that matches and doesn't raise an exception. If a handler raises an exception, it falls back to the next matching handler, then to static messages, and finally to the default message.
113
220
  ```
114
221
 
115
222
  ## Callbacks
116
223
 
117
224
  In addition to the [global exception handler](/reference/configuration#on-exception), 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.
118
225
 
119
- ::: danger ALPHA
120
- * The callbacks themselves are functional. Note the ordering _between_ callbacks is not well defined (currently a side effect of the order they're defined).
121
- * Ordering may change at any time so while in alpha DO NOT MAKE ASSUMPTIONS ABOUT THE ORDER OF CALLBACK EXECUTION!
226
+ ::: tip Callback Ordering
227
+ * Callbacks are executed in **last-defined-first** order, similar to messages
228
+ * Child class callbacks execute before parent class callbacks
229
+ * Multiple matching callbacks of the same type will *all* execute
122
230
  :::
123
231
 
124
232
 
@@ -153,17 +261,30 @@ class Foo
153
261
  end
154
262
  ```
155
263
 
156
- Note that by default the `on_exception` block will be applied to _any_ `StandardError` that is raised, but you can specify a matcher using the same logic as for [`error_from` and `rescues`](#error-for-and-rescues):
264
+ Note that by default the `on_exception` block will be applied to _any_ `StandardError` that is raised, but you can specify a matcher using the same logic as for conditional messages (`if:` or `unless:`):
157
265
 
158
266
  ```ruby
159
267
  class Foo
160
268
  include Action
161
269
 
162
- on_exception NoMethodError do |exception| # [!code focus]
270
+ on_exception(if: NoMethodError) do |exception| # [!code focus]
163
271
  # e.g. trigger a slack error
164
272
  end
165
273
 
166
- on_exception ->(e) { e.is_a?(ZeroDivisionError) } do # [!code focus]
274
+ on_exception(unless: :transient_error?) do |exception| # [!code focus]
275
+ # e.g. trigger a slack error for non-transient errors
276
+ end
277
+
278
+ def transient_error?
279
+ # local decision based on inputs/outputs
280
+ name == "temporary"
281
+ end
282
+
283
+ ::: warning
284
+ You cannot use both `if:` and `unless:` for the same callback - this will raise an `ArgumentError`.
285
+ :::
286
+
287
+ on_exception(if: ->(e) { e.is_a?(ZeroDivisionError) }) do # [!code focus]
167
288
  # e.g. trigger a slack error
168
289
  end
169
290
  end
@@ -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`
@@ -68,7 +66,7 @@ If you're using an APM provider, observability can be greatly enhanced by adding
68
66
  The framework provides two distinct hooks for observability:
69
67
 
70
68
  - **`wrap_with_trace`**: An around hook that wraps the entire action execution. You MUST call the provided block to execute the action.
71
- - **`emit_metrics`**: A post-execution hook that receives the action outcome. Do NOT call any blocks.
69
+ - **`emit_metrics`**: A post-execution hook that receives the action result. Do NOT call any blocks.
72
70
 
73
71
  For example, to wire up Datadog:
74
72
 
@@ -80,8 +78,9 @@ For example, to wire up Datadog:
80
78
  end
81
79
  end
82
80
 
83
- c.emit_metrics = proc do |resource, outcome|
84
- TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome:, resource: })
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: })
85
84
  end
86
85
  end
87
86
  ```
@@ -89,9 +88,9 @@ For example, to wire up Datadog:
89
88
  A couple notes:
90
89
 
91
90
  * `Datadog::Tracing` is provided by [the datadog gem](https://rubygems.org/gems/datadog)
92
- * `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.
93
92
  * The `wrap_with_trace` hook is an around hook - you must call the provided block to execute the action
94
- * The `emit_metrics` hook is called after execution with the outcome - do not call any blocks
93
+ * The `emit_metrics` hook is called after execution with the result - do not call any blocks
95
94
 
96
95
 
97
96
  ## `logger`
@@ -112,11 +111,11 @@ For example:
112
111
 
113
112
  For a practical example of this in practice, see [our 'memoization' recipe](/recipes/memoization).
114
113
 
115
- ## `default_log_level`
114
+ ## `log_level`
116
115
 
117
- 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.
118
117
 
119
- ## `default_autolog_level`
118
+ ## Automatic Logging
120
119
 
121
120
  By default, every `action.call` will emit log lines when it is called and after it completes:
122
121
 
@@ -125,4 +124,27 @@ By default, every `action.call` will emit log lines when it is called and after
125
124
  [YourCustomAction] Execution completed (with outcome: success) in 0.957 milliseconds
126
125
  ```
127
126
 
128
- 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.
@@ -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
@@ -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
@@ -3,10 +3,9 @@
3
3
  module Action
4
4
  class Configuration
5
5
  attr_accessor :wrap_with_trace, :emit_metrics
6
- attr_writer :logger, :env, :on_exception, :additional_includes, :default_log_level, :default_autolog_level
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
 
@@ -1,28 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # NOTE: This is a temporary file to be removed when we have a better way to handle context.
4
- # rubocop:disable Style/OpenStructUse, Style/CaseEquality
5
- require "ostruct"
6
-
7
3
  module Action
8
- class Context < OpenStruct
9
- def self.build(context = {})
10
- self === context ? context : new(context)
11
- end
4
+ class Context
5
+ attr_accessor :provided_data, :exposed_data
12
6
 
13
- def success?
14
- !failure?
15
- end
7
+ def initialize(**provided_data)
8
+ @provided_data = provided_data
9
+ @exposed_data = {}
16
10
 
17
- def failure?
18
- @failure || false
11
+ # Framework-managed fields
12
+ @failure = false
13
+ @exception = nil
14
+ @error_from_user = nil
15
+ @error_prefix = nil
16
+ @elapsed_time = nil
19
17
  end
20
18
 
21
- def fail!(context = {})
22
- context.each { |key, value| self[key.to_sym] = value }
23
- @failure = true
24
- raise Action::Failure, self
19
+ def fail!(message = nil)
20
+ @error_from_user = message if message.present?
21
+ raise Action::Failure, message
25
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=
26
37
  end
27
38
  end
28
- # rubocop:enable Style/OpenStructUse, Style/CaseEquality
@@ -7,19 +7,34 @@ module Action
7
7
  base.class_eval do
8
8
  extend ClassMethods
9
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
10
13
  end
11
14
  end
12
15
 
13
16
  module ClassMethods
14
- def autolog_level = Action.config.default_autolog_level
17
+ def auto_log(level)
18
+ self.auto_log_level = level.presence
19
+ end
15
20
  end
16
21
 
17
22
  module InstanceMethods
18
23
  private
19
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
+
20
32
  def _log_before
21
- public_send(
22
- self.class.autolog_level,
33
+ level = self.class.auto_log_level
34
+ return unless level
35
+
36
+ self.class.public_send(
37
+ level,
23
38
  [
24
39
  "About to execute",
25
40
  _log_context(:inbound),
@@ -30,13 +45,14 @@ module Action
30
45
  Axn::Util.piping_error("logging before hook", action: self, exception: e)
31
46
  end
32
47
 
33
- def _log_after(outcome:, timing_start:)
34
- elapsed_mils = Core::Timing.elapsed_ms(timing_start)
48
+ def _log_after
49
+ level = self.class.auto_log_level
50
+ return unless level
35
51
 
36
- public_send(
37
- self.class.autolog_level,
52
+ self.class.public_send(
53
+ level,
38
54
  [
39
- "Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
55
+ "Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
40
56
  _log_context(:outbound),
41
57
  ].compact.join(". Set: "),
42
58
  after: Action.config.env.production? ? nil : "\n------\n",
@@ -0,0 +1,39 @@
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
+ end
39
+ 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