axn 0.1.0.pre.alpha.2.7 → 0.1.0.pre.alpha.2.8

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +11 -5
  3. data/CHANGELOG.md +14 -1
  4. data/Rakefile +12 -0
  5. data/docs/intro/about.md +2 -2
  6. data/docs/intro/overview.md +18 -0
  7. data/docs/recipes/rubocop-integration.md +352 -0
  8. data/docs/reference/action-result.md +2 -2
  9. data/docs/reference/class.md +122 -6
  10. data/docs/reference/configuration.md +43 -9
  11. data/docs/reference/instance.md +0 -52
  12. data/docs/usage/setup.md +4 -0
  13. data/docs/usage/steps.md +335 -0
  14. data/docs/usage/writing.md +70 -3
  15. data/lib/action/attachable/steps.rb +18 -17
  16. data/lib/action/attachable/subactions.rb +1 -1
  17. data/lib/action/context.rb +10 -14
  18. data/lib/action/core/context/facade.rb +11 -2
  19. data/lib/action/core/context/facade_inspector.rb +3 -2
  20. data/lib/action/core/context/internal.rb +3 -11
  21. data/lib/action/core/contract_validation.rb +1 -1
  22. data/lib/action/core/flow/callbacks.rb +27 -12
  23. data/lib/action/core/flow/exception_execution.rb +2 -5
  24. data/lib/action/core/flow/handlers/{base_handler.rb → base_descriptor.rb} +7 -4
  25. data/lib/action/core/flow/handlers/descriptors/callback_descriptor.rb +17 -0
  26. data/lib/action/core/flow/handlers/descriptors/message_descriptor.rb +53 -0
  27. data/lib/action/core/flow/handlers/matcher.rb +41 -2
  28. data/lib/action/core/flow/handlers/resolvers/base_resolver.rb +28 -0
  29. data/lib/action/core/flow/handlers/resolvers/callback_resolver.rb +29 -0
  30. data/lib/action/core/flow/handlers/resolvers/message_resolver.rb +59 -0
  31. data/lib/action/core/flow/handlers.rb +7 -4
  32. data/lib/action/core/flow/messages.rb +15 -41
  33. data/lib/action/core/nesting_tracking.rb +31 -0
  34. data/lib/action/core/timing.rb +1 -1
  35. data/lib/action/core.rb +20 -12
  36. data/lib/action/exceptions.rb +20 -2
  37. data/lib/action/result.rb +30 -32
  38. data/lib/axn/factory.rb +22 -23
  39. data/lib/axn/rubocop.rb +10 -0
  40. data/lib/axn/version.rb +1 -1
  41. data/lib/rubocop/cop/axn/README.md +237 -0
  42. data/lib/rubocop/cop/axn/unchecked_result.rb +327 -0
  43. metadata +14 -6
  44. data/lib/action/core/flow/handlers/callback_handler.rb +0 -21
  45. data/lib/action/core/flow/handlers/message_handler.rb +0 -27
  46. data/lib/action/core/hoist_errors.rb +0 -58
@@ -2,7 +2,6 @@
2
2
 
3
3
  Somewhere at boot (e.g. `config/initializers/actions.rb` in Rails), you can call `Action.configure` to adjust a few global settings.
4
4
 
5
-
6
5
  ```ruby
7
6
  Action.configure do |c|
8
7
  c.log_level = :info
@@ -22,7 +21,6 @@ By default any swallowed errors are noted in the logs, but it's _highly recommen
22
21
 
23
22
  For example, if you're using Honeybadger this could look something like:
24
23
 
25
-
26
24
  ```ruby
27
25
  Action.configure do |c|
28
26
  c.on_exception = proc do |e, action:, context:|
@@ -56,13 +54,10 @@ A couple notes:
56
54
  * If your handler raises, the failure will _also_ be swallowed and logged
57
55
  * This handler is global across _all_ Axns. You can also specify per-Action handlers via [the class-level declaration](/reference/class#on-exception).
58
56
 
59
-
60
- ## `top_level_around_hook`
57
+ ## `wrap_with_trace` and `emit_metrics`
61
58
 
62
59
  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.
63
60
 
64
- ### Tracing and Metrics
65
-
66
61
  The framework provides two distinct hooks for observability:
67
62
 
68
63
  - **`wrap_with_trace`**: An around hook that wraps the entire action execution. You MUST call the provided block to execute the action.
@@ -79,7 +74,7 @@ For example, to wire up Datadog:
79
74
  end
80
75
 
81
76
  c.emit_metrics = proc do |resource, result|
82
- TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome: result.outcome, resource: })
77
+ TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome: result.outcome.to_s, resource: })
83
78
  TS::Metrics.histogram("action.duration", result.elapsed_time, tags: { resource: })
84
79
  end
85
80
  end
@@ -88,15 +83,16 @@ For example, to wire up Datadog:
88
83
  A couple notes:
89
84
 
90
85
  * `Datadog::Tracing` is provided by [the datadog gem](https://rubygems.org/gems/datadog)
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.
86
+ * `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 (`result.outcome.success?`, `result.outcome.failure?`, `result.outcome.exception?`) and elapsed time of the action.
92
87
  * The `wrap_with_trace` hook is an around hook - you must call the provided block to execute the action
93
88
  * The `emit_metrics` hook is called after execution with the result - do not call any blocks
94
89
 
95
-
96
90
  ## `logger`
97
91
 
98
92
  Defaults to `Rails.logger`, if present, otherwise falls back to `Logger.new($stdout)`. But can be set to a custom logger as necessary.
99
93
 
94
+
95
+
100
96
  ## `additional_includes`
101
97
 
102
98
  This is much less critical than the preceding options, but on the off chance you want to add additional customization to _all_ your actions you can set additional modules to be included alongside `include Action`.
@@ -115,6 +111,10 @@ For a practical example of this in practice, see [our 'memoization' recipe](/rec
115
111
 
116
112
  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.
117
113
 
114
+ ## `env`
115
+
116
+ Automatically detects the environment from `RACK_ENV` or `RAILS_ENV`, defaulting to `"development"`. This is used internally for conditional behavior (e.g., more verbose logging in non-production environments).
117
+
118
118
  ## Automatic Logging
119
119
 
120
120
  By default, every `action.call` will emit log lines when it is called and after it completes:
@@ -148,3 +148,37 @@ end
148
148
  ```
149
149
 
150
150
  The `auto_log` method supports inheritance, so subclasses will inherit the setting from their parent class unless explicitly overridden.
151
+
152
+ ## Complete Configuration Example
153
+
154
+ Here's a complete example showing all available configuration options:
155
+
156
+ ```ruby
157
+ Action.configure do |c|
158
+ # Logging
159
+ c.log_level = :info
160
+ c.logger = Rails.logger
161
+
162
+ # Exception handling
163
+ c.on_exception = proc do |e, action:, context:|
164
+ message = "[#{action.class.name}] Failing due to #{e.class.name}: #{e.message}"
165
+ Rails.logger.warn(message)
166
+ Honeybadger.notify(message, context: { axn_context: context })
167
+ end
168
+
169
+ # Observability
170
+ c.wrap_with_trace = proc do |resource, &action|
171
+ Datadog::Tracing.trace("Action", resource:) do
172
+ action.call
173
+ end
174
+ end
175
+
176
+ c.emit_metrics = proc do |resource, result|
177
+ Datadog::Metrics.increment("action.#{resource.underscore}", tags: { outcome: result.outcome.to_s })
178
+ Datadog::Metrics.histogram("action.duration", result.elapsed_time, tags: { resource: })
179
+ end
180
+
181
+ # Global includes
182
+ c.additional_includes = [MyCustomModule]
183
+ end
184
+ ```
@@ -51,56 +51,4 @@ class Foo
51
51
  end
52
52
  ```
53
53
 
54
- ## `#hoist_errors`
55
54
 
56
- Useful when calling one Action from within another. By default the nested action call will return an Action::Result, but it's up to you to check if the result is `ok?` and to handle potential failure modes... and in practice this is easy to miss.
57
-
58
- By wrapping your nested call in `hoist_errors`, it will _automatically_ fail the parent action if the nested call fails.
59
-
60
- Accepts a `prefix` keyword argument -- when set, prefixes the `error` message from any failures in the block (useful to return different error messages for each if you're calling multiple sub-actions in a single service).
61
-
62
- NOTE: expects a single action call in the block -- if there are multiple calls, only the last one will be checked for `ok?` (although anything _raised_ in the block will still be handled).
63
-
64
- ::: tip Versus `call!`
65
- * If you just want to make sure your action fails if the subaction fails: call subaction via `call!` (any failures will raise, which will fail the parent).
66
- * Note this passes _child_ exception into _parent_ `messages :error` parsing.
67
- * If you want _the child's_ `result.error` to become the _parent's_ `result.error` on failure, use `hoist_errors` + `call`
68
- :::
69
-
70
- ### Example
71
-
72
- ```ruby
73
- class SubAction
74
- include Action
75
-
76
- def call
77
- fail! "bad news"
78
- end
79
- end
80
-
81
- class MainAction
82
- include Action
83
-
84
- def call
85
- SubAction.call
86
- end
87
- end
88
- ```
89
-
90
- _Without_ `hoist_errors`, `MainAction.call` returns an `ok?` result, even though `SubAction.call` always fails, because we haven't explicitly handled the nested call.
91
-
92
- By adding `hoist_errors`, though:
93
-
94
- ```ruby
95
- class MainAction
96
- include Action
97
-
98
- def call
99
- hoist_errors(prefix: "From subaction:") do
100
- SubAction.call
101
- end
102
- end
103
- end
104
- ```
105
-
106
- `MainAction.call` now returns a _failed_ result, and `result.error` is "From subaction: bad news".
data/docs/usage/setup.md CHANGED
@@ -21,4 +21,8 @@ By default any swallowed errors are noted in the logs, but it's _highly recommen
21
21
 
22
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
+ ### Code Quality (Optional)
25
+
26
+ For teams using RuboCop, Axn provides custom cops to enforce best practices. See the [RuboCop Integration guide](/recipes/rubocop-integration) for setup instructions.
27
+
24
28
 
@@ -0,0 +1,335 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+
5
+ # Using Steps in Actions
6
+
7
+ The steps functionality allows you to compose complex actions by breaking them down into sequential, reusable steps. Each step can expect data from the parent context or previous steps, and expose data for subsequent steps.
8
+
9
+ ## Basic Concepts
10
+
11
+ ### What are Steps?
12
+
13
+ Steps are a way to organize action logic into smaller, focused pieces that:
14
+ - Execute in a defined order
15
+ - Can share data between each other
16
+ - Handle failures gracefully with error prefixing
17
+ - Can be reused across different actions
18
+
19
+ ### How Steps Work
20
+
21
+ 1. **Step Definition**: Define steps using the `step` class method
22
+ 2. **Execution Order**: Steps execute sequentially in the order they're defined
23
+ 3. **Data Flow**: Each step can expect and expose data
24
+ 4. **Error Handling**: Step failures are caught and can trigger error handlers
25
+
26
+ ## Defining Steps
27
+
28
+ ### Using the `step` Method
29
+
30
+ The `step` method allows you to define steps inline with blocks:
31
+
32
+ ```ruby
33
+ class UserRegistration
34
+ include Action
35
+ expects :email, :password, :name
36
+ exposes :user_id, :welcome_message
37
+
38
+ step :validate_input, expects: [:email, :password, :name], exposes: [:validated_data] do
39
+ # Validation logic
40
+ fail! "Email is invalid" unless email.match?(/\A[^@\s]+@[^@\s]+\z/)
41
+ fail! "Password too short" if password.length < 8
42
+ fail! "Name is required" if name.blank?
43
+
44
+ expose :validated_data, { email: email.downcase, password: password, name: name.strip }
45
+ end
46
+
47
+ step :create_user, expects: [:validated_data], exposes: [:user_id] do
48
+ user = User.create!(validated_data)
49
+ expose :user_id, user.id
50
+ end
51
+
52
+ step :send_welcome, expects: [:user_id, :validated_data], exposes: [:welcome_message] do
53
+ WelcomeMailer.send_welcome(user_id, validated_data[:email]).deliver_now
54
+ expose :welcome_message, "Welcome #{validated_data[:name]}!"
55
+ end
56
+
57
+ def call
58
+ # Steps handle execution automatically
59
+ end
60
+ end
61
+ ```
62
+
63
+ ### Using the `steps` Method
64
+
65
+ The `steps` method allows you to compose existing action classes:
66
+
67
+ ```ruby
68
+ class ValidateInput
69
+ include Action
70
+ expects :email, :password, :name
71
+ exposes :validated_data
72
+
73
+ def call
74
+ fail! "Email is invalid" unless email.match?(/\A[^@\s]+@[^@\s]+\z/)
75
+ fail! "Password too short" if password.length < 8
76
+ fail! "Name is required" if name.blank?
77
+
78
+ expose :validated_data, { email: email.downcase, password: password, name: name.strip }
79
+ end
80
+ end
81
+
82
+ class CreateUser
83
+ include Action
84
+ expects :validated_data
85
+ exposes :user_id
86
+
87
+ def call
88
+ user = User.create!(validated_data)
89
+ expose :user_id, user.id
90
+ end
91
+ end
92
+
93
+ class SendWelcome
94
+ include Action
95
+ expects :user_id, :validated_data
96
+ exposes :welcome_message
97
+
98
+ def call
99
+ WelcomeMailer.send_welcome(user_id, validated_data[:email]).deliver_now
100
+ expose :welcome_message, "Welcome #{validated_data[:name]}!"
101
+ end
102
+ end
103
+
104
+ class UserRegistration
105
+ include Action
106
+ expects :email, :password, :name
107
+ exposes :user_id, :welcome_message
108
+
109
+ # Use existing action classes as steps
110
+ steps(ValidateInput, CreateUser, SendWelcome)
111
+ end
112
+ ```
113
+
114
+ ### Mixed Approach
115
+
116
+ You can combine both approaches:
117
+
118
+ ```ruby
119
+ class UserRegistration
120
+ include Action
121
+ expects :email, :password, :name
122
+ exposes :user_id, :welcome_message
123
+
124
+ # Use existing action for validation
125
+ steps(ValidateInput)
126
+
127
+ # Define custom step for user creation
128
+ step :create_user, expects: [:validated_data], exposes: [:user_id] do
129
+ user = User.create!(validated_data)
130
+ expose :user_id, user.id
131
+ end
132
+
133
+ # Use existing action for welcome email
134
+ steps(SendWelcome)
135
+ end
136
+ ```
137
+
138
+ ## Data Flow Between Steps
139
+
140
+ ### Expecting Data
141
+
142
+ Steps can expect data from:
143
+ - **Parent context**: Data passed to the parent action
144
+ - **Previous steps**: Data exposed by earlier steps
145
+
146
+ ```ruby
147
+ step :step1, expects: [:input], exposes: [:processed_data] do
148
+ expose :processed_data, input.upcase
149
+ end
150
+
151
+ step :step2, expects: [:processed_data], exposes: [:final_result] do
152
+ # This step can access both 'input' (from parent) and 'processed_data' (from step1)
153
+ expose :final_result, "Result: #{processed_data}"
154
+ end
155
+ ```
156
+
157
+ ### Exposing Data
158
+
159
+ Steps expose data using the `expose` method:
160
+
161
+ ```ruby
162
+ step :calculation, expects: [:base_value], exposes: [:doubled_value, :final_result] do
163
+ doubled = base_value * 2
164
+ expose :doubled_value, doubled
165
+ expose :final_result, doubled + 10
166
+ end
167
+ ```
168
+
169
+ ### Using `expose_return_as`
170
+
171
+ For simple calculations, you can use `expose_return_as`:
172
+
173
+ ```ruby
174
+ step :calculation, expects: [:input], expose_return_as: :result do
175
+ input * 2 + 10 # Return value is automatically exposed as 'result'
176
+ end
177
+ ```
178
+
179
+ ## Error Handling
180
+
181
+ ### Automatic Error Prefixing
182
+
183
+ When a step fails, error messages are automatically prefixed with the step name:
184
+
185
+ ```ruby
186
+ step :validation, expects: [:input] do
187
+ fail! "Input too short"
188
+ end
189
+
190
+ # If this step fails, the error message becomes: "validation step: Input too short"
191
+ ```
192
+
193
+ ### Step Failure Propagation
194
+
195
+ When a step fails:
196
+ 1. The step's exception is caught
197
+ 2. The parent action fails with the prefixed error message
198
+ 3. The `on_exception` handlers are triggered appropriately
199
+
200
+ ### Exception Handling
201
+
202
+ Steps can raise exceptions that will be caught and handled:
203
+
204
+ ```ruby
205
+ step :risky_operation, expects: [:input] do
206
+ raise StandardError, "Something went wrong with #{input}"
207
+ end
208
+
209
+ # The exception is caught and the error message becomes: "risky_operation step: Something went wrong with [input]"
210
+ ```
211
+
212
+
213
+
214
+ ## Best Practices
215
+
216
+ ### 1. Keep Steps Focused
217
+
218
+ Each step should have a single responsibility:
219
+
220
+ ```ruby
221
+ # ❌ Bad: Step does too many things
222
+ step :process_user, expects: [:user_data], exposes: [:user_id, :welcome_sent] do
223
+ user = User.create!(user_data)
224
+ WelcomeMailer.send_welcome(user.id).deliver_now
225
+ expose :user_id, user.id
226
+ expose :welcome_sent, true
227
+ end
228
+
229
+ # ✅ Good: Steps are focused
230
+ step :create_user, expects: [:user_data], exposes: [:user_id] do
231
+ user = User.create!(user_data)
232
+ expose :user_id, user.id
233
+ end
234
+
235
+ step :send_welcome, expects: [:user_id], exposes: [:welcome_sent] do
236
+ WelcomeMailer.send_welcome(user_id).deliver_now
237
+ expose :welcome_sent, true
238
+ end
239
+ ```
240
+
241
+ ### 2. Use Descriptive Step Names
242
+
243
+ Step names should clearly indicate what the step does:
244
+
245
+ ```ruby
246
+ # ❌ Bad: Unclear names
247
+ step :step1, expects: [:input] do
248
+ # ...
249
+ end
250
+
251
+ # ✅ Good: Descriptive names
252
+ step :validate_email_format, expects: [:input] do
253
+ # ...
254
+ end
255
+ ```
256
+
257
+ ### 3. Handle Failures Gracefully
258
+
259
+ Use `fail!` for expected failures and raise exceptions for unexpected errors:
260
+
261
+ ```ruby
262
+ step :validation, expects: [:input] do
263
+ # Expected failure - use fail!
264
+ fail! "Input too short" if input.length < 3
265
+
266
+ # Unexpected error - raise exception
267
+ raise StandardError, "Database connection failed" if database_unavailable?
268
+ end
269
+ ```
270
+
271
+ ### 4. Expose Only Necessary Data
272
+
273
+ Only expose data that subsequent steps actually need:
274
+
275
+ ```ruby
276
+ # ❌ Bad: Exposing unnecessary data
277
+ step :validation, expects: [:input], exposes: [:input, :validated, :timestamp] do
278
+ expose :input, input
279
+ expose :validated, true
280
+ expose :timestamp, Time.current
281
+ end
282
+
283
+ # ✅ Good: Only exposing what's needed
284
+ step :validation, expects: [:input], exposes: [:validated_input] do
285
+ expose :validated_input, input.strip
286
+ end
287
+ ```
288
+
289
+ ## Common Use Cases
290
+
291
+ ### API Request Processing
292
+
293
+ ```ruby
294
+ class ProcessAPIRequest
295
+ include Action
296
+ expects :request_data
297
+ exposes :response_data
298
+
299
+ step :authenticate, expects: [:request_data], exposes: [:authenticated_user] do
300
+ # Authentication logic
301
+ expose :authenticated_user, authenticate_user(request_data[:token])
302
+ end
303
+
304
+ step :authorize, expects: [:authenticated_user, :request_data], exposes: [:authorized] do
305
+ # Authorization logic
306
+ fail! "Access denied" unless authorized_user?(authenticated_user, request_data[:action])
307
+ expose :authorized, true
308
+ end
309
+
310
+ step :process_request, expects: [:request_data, :authenticated_user], exposes: [:response_data] do
311
+ # Process the actual request
312
+ expose :response_data, process_user_request(request_data, authenticated_user)
313
+ end
314
+ end
315
+ ```
316
+
317
+
318
+
319
+ ## Troubleshooting
320
+
321
+ ### Common Issues
322
+
323
+ 1. **Steps not executing**: Ensure the Steps module is properly included
324
+ 2. **Data not flowing**: Check that step names match between `expects` and `exposes`
325
+ 3. **Error messages unclear**: Verify step names are descriptive
326
+
327
+ ### Debugging Tips
328
+
329
+ - Use descriptive step names for better error messages
330
+ - Check that data is properly exposed between steps
331
+ - Verify that step dependencies are correctly specified
332
+
333
+ ## Summary
334
+
335
+ The steps functionality provides a powerful way to compose complex actions from smaller, focused pieces. By following the patterns and best practices outlined here, you can create maintainable, testable, and reusable action compositions.
@@ -68,7 +68,7 @@ See [the reference doc](/reference/instance) for a few more handy helper methods
68
68
 
69
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
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).
71
+ There are `success` and `error` declarations 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
72
 
73
73
  For instance, configuring the action like this:
74
74
 
@@ -79,8 +79,8 @@ class Foo
79
79
  expects :name, type: String
80
80
  exposes :meaning_of_life
81
81
 
82
- messages success: -> { "Revealed to #{name}: #{result.meaning_of_life}" }, # [!code focus:2]
83
- error: ->(e) { "No secret of life for you: #{e.message}" }
82
+ success { "Revealed to #{name}: #{result.meaning_of_life}" } # [!code focus:2]
83
+ error { |e| "No secret of life for you: #{e.message}" }
84
84
 
85
85
  def call
86
86
  fail! "Douglas already knows the meaning" if name == "Doug"
@@ -100,6 +100,71 @@ Foo.call(name: "Adams").success # => "Revealed the secret of life to Adams"
100
100
  Foo.call(name: "Adams").meaning_of_life # => "Hello Adams, the meaning of life is 42"
101
101
  ```
102
102
 
103
+ ### Advanced Error Message Configuration
104
+
105
+ You can also use conditional error messages with the `prefix:` keyword and combine them with the `from:` parameter for nested actions:
106
+
107
+ ```ruby
108
+ class ValidationAction
109
+ include Action
110
+
111
+ expects :input
112
+
113
+ error if: ArgumentError, prefix: "Validation Error: " do |e|
114
+ "Invalid input: #{e.message}"
115
+ end
116
+
117
+ error if: StandardError, prefix: "System Error: "
118
+
119
+ def call
120
+ raise ArgumentError, "input too short" if input.length < 3
121
+ raise StandardError, "unexpected error" if input == "error"
122
+ end
123
+ end
124
+
125
+ class ApiAction
126
+ include Action
127
+
128
+ expects :data
129
+
130
+ # Combine prefix with from for consistent error formatting
131
+ error from: ValidationAction, prefix: "API Error: " do |e|
132
+ "Request validation failed: #{e.message}"
133
+ end
134
+
135
+ # Or use prefix only (falls back to exception message)
136
+ error from: ValidationAction, prefix: "API Error: "
137
+
138
+ def call
139
+ ValidationAction.call!(input: data)
140
+ end
141
+ end
142
+ ```
143
+
144
+ This configuration provides:
145
+ - Consistent error message formatting with prefixes
146
+ - Automatic fallback to exception messages when no custom message is provided
147
+ - Proper error message inheritance from nested actions
148
+
149
+ ::: warning Message Ordering
150
+ **Important**: When using conditional messages, always define your static fallback messages **first** in your class, before any conditional messages. This ensures proper fallback behavior.
151
+
152
+ **Correct order:**
153
+ ```ruby
154
+ class Foo
155
+ include Action
156
+
157
+ # Static fallback messages first
158
+ success "Default success message"
159
+ error "Default error message"
160
+
161
+ # Then conditional messages
162
+ success "Special success", if: :special_condition?
163
+ error "Special error", if: ArgumentError
164
+ end
165
+ ```
166
+ :::
167
+
103
168
  ## Lifecycle methods
104
169
 
105
170
  In addition to `#call`, there are a few additional pieces to be aware of:
@@ -156,3 +221,5 @@ A number of custom callback are available for you as well, if you want to take s
156
221
 
157
222
  ## Strategies
158
223
  A number of [Strategies](/strategies/index), which are <abbr title="Don't Repeat Yourself">DRY</abbr>ed bits of commonly-used configuration, are available for your use as well.
224
+
225
+ ```
@@ -9,14 +9,16 @@ module Action
9
9
  class_attribute :_axn_steps, default: []
10
10
  end
11
11
 
12
- Entry = Data.define(:label, :axn)
13
-
14
12
  class_methods do
15
13
  def steps(*steps)
16
- self._axn_steps += Array(steps).compact
14
+ Array(steps).compact.each do |step|
15
+ raise ArgumentError, "Step #{step} must include Action module" if step.is_a?(Class) && !step.included_modules.include?(Action) && !step < Action
16
+
17
+ step("Step #{_axn_steps.length + 1}", step)
18
+ end
17
19
  end
18
20
 
19
- def step(name, axn_klass = nil, **kwargs, &block)
21
+ def step(name, axn_klass = nil, error_prefix: nil, **kwargs, &block)
20
22
  axn_klass = axn_for_attachment(
21
23
  name:,
22
24
  axn_klass:,
@@ -27,32 +29,31 @@ module Action
27
29
  )
28
30
 
29
31
  # Add the step to the list of steps
30
- steps Entry.new(label: name, axn: axn_klass)
32
+ _axn_steps << axn_klass
33
+
34
+ # Set up error handling for steps without explicit labels
35
+ error_prefix ||= "#{name}: "
36
+ error from: axn_klass do |e|
37
+ "#{error_prefix}#{e.message}"
38
+ end
31
39
  end
32
40
  end
33
41
 
42
+ # Execute steps automatically when the action is called
34
43
  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(**merged_context_data).tap do |step_result|
42
- merge_step_exposures!(step_result)
43
- end
44
- end
44
+ _axn_steps.each do |axn|
45
+ _merge_step_exposures!(axn.call!(**_merged_context_data))
45
46
  end
46
47
  end
47
48
 
48
49
  private
49
50
 
50
- def merged_context_data
51
+ def _merged_context_data
51
52
  @__context.__combined_data
52
53
  end
53
54
 
54
55
  # Each step can expect the data exposed from the previous steps
55
- def merge_step_exposures!(step_result)
56
+ def _merge_step_exposures!(step_result)
56
57
  step_result.declared_fields.each do |field|
57
58
  @__context.exposed_data[field] = step_result.public_send(field)
58
59
  end
@@ -40,7 +40,7 @@ module Action
40
40
  axn_klass.call(**kwargs)
41
41
  end
42
42
 
43
- # TODO: do we also need an instance-level version that auto-wraps in hoist_errors(label: name)?
43
+ # TODO: do we also need an instance-level version that auto-creates the appropriate `error from:` to prefix with the name?
44
44
 
45
45
  define_singleton_method("#{name}!") do |**kwargs|
46
46
  axn_klass.call!(**kwargs)