axn 0.1.0.pre.alpha.2.8.1 → 0.1.0.pre.alpha.4

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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/.cursor/rules/axn-framework-patterns.mdc +43 -0
  4. data/.cursor/rules/general-coding-standards.mdc +27 -0
  5. data/.cursor/rules/spec/testing-patterns.mdc +40 -0
  6. data/CHANGELOG.md +57 -0
  7. data/Rakefile +114 -4
  8. data/docs/.vitepress/config.mjs +19 -10
  9. data/docs/advanced/conventions.md +3 -3
  10. data/docs/advanced/mountable.md +476 -0
  11. data/docs/advanced/profiling.md +351 -0
  12. data/docs/advanced/rough.md +27 -8
  13. data/docs/index.md +5 -3
  14. data/docs/intro/about.md +1 -1
  15. data/docs/intro/overview.md +6 -6
  16. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  17. data/docs/recipes/memoization.md +103 -18
  18. data/docs/recipes/rubocop-integration.md +38 -284
  19. data/docs/recipes/testing.md +14 -14
  20. data/docs/recipes/validating-user-input.md +1 -1
  21. data/docs/reference/async.md +429 -0
  22. data/docs/reference/axn-result.md +107 -0
  23. data/docs/reference/class.md +225 -64
  24. data/docs/reference/configuration.md +366 -34
  25. data/docs/reference/form-object.md +252 -0
  26. data/docs/reference/instance.md +14 -29
  27. data/docs/strategies/client.md +212 -0
  28. data/docs/strategies/form.md +235 -0
  29. data/docs/strategies/index.md +21 -21
  30. data/docs/strategies/transaction.md +1 -1
  31. data/docs/usage/setup.md +16 -2
  32. data/docs/usage/steps.md +7 -7
  33. data/docs/usage/using.md +23 -12
  34. data/docs/usage/writing.md +191 -12
  35. data/lib/axn/async/adapters/active_job.rb +74 -0
  36. data/lib/axn/async/adapters/disabled.rb +41 -0
  37. data/lib/axn/async/adapters/sidekiq.rb +67 -0
  38. data/lib/axn/async/adapters.rb +26 -0
  39. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  40. data/lib/axn/async/batch_enqueue.rb +99 -0
  41. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  42. data/lib/axn/async.rb +178 -0
  43. data/lib/axn/configuration.rb +113 -0
  44. data/lib/{action → axn}/context.rb +22 -4
  45. data/lib/axn/core/automatic_logging.rb +89 -0
  46. data/lib/axn/core/context/facade.rb +69 -0
  47. data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
  48. data/lib/{action → axn}/core/context/internal.rb +5 -5
  49. data/lib/{action → axn}/core/contract.rb +111 -73
  50. data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
  51. data/lib/{action → axn}/core/contract_validation.rb +27 -12
  52. data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
  53. data/lib/axn/core/default_call.rb +63 -0
  54. data/lib/axn/core/field_resolvers/extract.rb +32 -0
  55. data/lib/axn/core/field_resolvers/model.rb +63 -0
  56. data/lib/axn/core/field_resolvers.rb +24 -0
  57. data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
  58. data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
  59. data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
  60. data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
  61. data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
  62. data/lib/axn/core/flow/handlers/invoker.rb +47 -0
  63. data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
  64. data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
  65. data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
  66. data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
  67. data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
  68. data/lib/axn/core/flow/handlers.rb +20 -0
  69. data/lib/{action → axn}/core/flow/messages.rb +8 -8
  70. data/lib/{action → axn}/core/flow.rb +4 -4
  71. data/lib/{action → axn}/core/hooks.rb +17 -5
  72. data/lib/axn/core/logging.rb +48 -0
  73. data/lib/axn/core/memoization.rb +53 -0
  74. data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
  75. data/lib/{action → axn}/core/timing.rb +1 -1
  76. data/lib/axn/core/tracing.rb +90 -0
  77. data/lib/axn/core/use_strategy.rb +29 -0
  78. data/lib/{action → axn}/core/validation/fields.rb +26 -2
  79. data/lib/{action → axn}/core/validation/subfields.rb +14 -12
  80. data/lib/axn/core/validation/validators/model_validator.rb +36 -0
  81. data/lib/axn/core/validation/validators/type_validator.rb +80 -0
  82. data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
  83. data/lib/{action → axn}/core.rb +55 -55
  84. data/lib/{action → axn}/exceptions.rb +12 -2
  85. data/lib/axn/extras/strategies/client.rb +150 -0
  86. data/lib/axn/extras/strategies/vernier.rb +121 -0
  87. data/lib/axn/extras.rb +4 -0
  88. data/lib/axn/factory.rb +122 -34
  89. data/lib/axn/form_object.rb +90 -0
  90. data/lib/axn/internal/logging.rb +30 -0
  91. data/lib/axn/internal/registry.rb +87 -0
  92. data/lib/axn/mountable/descriptor.rb +76 -0
  93. data/lib/axn/mountable/helpers/class_builder.rb +193 -0
  94. data/lib/axn/mountable/helpers/mounter.rb +33 -0
  95. data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
  96. data/lib/axn/mountable/helpers/validator.rb +112 -0
  97. data/lib/axn/mountable/inherit_profiles.rb +72 -0
  98. data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
  99. data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
  100. data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
  101. data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
  102. data/lib/axn/mountable/mounting_strategies.rb +32 -0
  103. data/lib/axn/mountable.rb +119 -0
  104. data/lib/axn/rails/engine.rb +51 -0
  105. data/lib/axn/rails/generators/axn_generator.rb +86 -0
  106. data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
  107. data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
  108. data/lib/{action → axn}/result.rb +32 -13
  109. data/lib/axn/strategies/form.rb +98 -0
  110. data/lib/axn/strategies/transaction.rb +26 -0
  111. data/lib/axn/strategies.rb +20 -0
  112. data/lib/axn/testing/spec_helpers.rb +6 -8
  113. data/lib/axn/util/callable.rb +120 -0
  114. data/lib/axn/util/contract_error_handling.rb +32 -0
  115. data/lib/axn/util/execution_context.rb +34 -0
  116. data/lib/axn/util/global_id_serialization.rb +52 -0
  117. data/lib/axn/util/logging.rb +87 -0
  118. data/lib/axn/util/memoization.rb +20 -0
  119. data/lib/axn/version.rb +1 -1
  120. data/lib/axn.rb +26 -16
  121. data/lib/rubocop/cop/axn/README.md +23 -23
  122. data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
  123. metadata +106 -64
  124. data/.rspec +0 -3
  125. data/.rubocop.yml +0 -76
  126. data/.tool-versions +0 -1
  127. data/docs/reference/action-result.md +0 -37
  128. data/lib/action/attachable/base.rb +0 -43
  129. data/lib/action/attachable/steps.rb +0 -63
  130. data/lib/action/attachable/subactions.rb +0 -70
  131. data/lib/action/attachable.rb +0 -17
  132. data/lib/action/configuration.rb +0 -55
  133. data/lib/action/core/automatic_logging.rb +0 -93
  134. data/lib/action/core/context/facade.rb +0 -48
  135. data/lib/action/core/flow/handlers/invoker.rb +0 -73
  136. data/lib/action/core/flow/handlers.rb +0 -20
  137. data/lib/action/core/logging.rb +0 -37
  138. data/lib/action/core/tracing.rb +0 -17
  139. data/lib/action/core/use_strategy.rb +0 -30
  140. data/lib/action/core/validation/validators/model_validator.rb +0 -34
  141. data/lib/action/core/validation/validators/type_validator.rb +0 -30
  142. data/lib/action/enqueueable/via_sidekiq.rb +0 -76
  143. data/lib/action/enqueueable.rb +0 -13
  144. data/lib/action/strategies/transaction.rb +0 -19
  145. data/lib/action/strategies.rb +0 -48
  146. data/lib/axn/util.rb +0 -24
  147. data/package.json +0 -10
  148. data/yarn.lock +0 -1166
@@ -0,0 +1,252 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+
5
+ # Axn::FormObject
6
+
7
+ `Axn::FormObject` is a base class for creating form objects that validate user input. It extends `ActiveModel::Model` with conveniences specifically designed for use with Axn actions.
8
+
9
+ ## Overview
10
+
11
+ Form objects provide a layer between raw user input and your domain logic. They:
12
+ - Validate user-facing input with friendly error messages
13
+ - Provide a clean interface for accessing validated data
14
+ - Support nested form objects for complex forms
15
+ - Automatically track field names for serialization
16
+
17
+ ## Basic Usage
18
+
19
+ ```ruby
20
+ class RegistrationForm < Axn::FormObject
21
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
22
+ validates :password, presence: true, length: { minimum: 8 }
23
+ validates :password_confirmation, presence: true
24
+
25
+ validate :passwords_match
26
+
27
+ private
28
+
29
+ def passwords_match
30
+ return if password == password_confirmation
31
+
32
+ errors.add(:password_confirmation, "doesn't match password")
33
+ end
34
+ end
35
+ ```
36
+
37
+ ## Auto-Generated Accessors
38
+
39
+ Unlike plain `ActiveModel::Model`, `Axn::FormObject` automatically creates `attr_accessor` methods for any field you validate:
40
+
41
+ ```ruby
42
+ class MyForm < Axn::FormObject
43
+ validates :name, presence: true # Automatically creates attr_accessor :name
44
+ validates :email, presence: true # Automatically creates attr_accessor :email
45
+ end
46
+
47
+ form = MyForm.new(name: "Alice", email: "alice@example.com")
48
+ form.name # => "Alice"
49
+ form.email # => "alice@example.com"
50
+ ```
51
+
52
+ You can also explicitly declare accessors:
53
+
54
+ ```ruby
55
+ class MyForm < Axn::FormObject
56
+ attr_accessor :optional_field # Tracked in field_names
57
+
58
+ validates :required_field, presence: true
59
+ end
60
+ ```
61
+
62
+ ## Field Name Tracking
63
+
64
+ `Axn::FormObject` tracks all declared fields in `field_names`, which is used for serialization:
65
+
66
+ ```ruby
67
+ class MyForm < Axn::FormObject
68
+ validates :name, presence: true
69
+ validates :email, presence: true
70
+ attr_accessor :notes
71
+ end
72
+
73
+ MyForm.field_names # => [:name, :email, :notes]
74
+ ```
75
+
76
+ ## Serialization with `#to_h`
77
+
78
+ The `#to_h` method converts the form object to a hash containing all tracked fields:
79
+
80
+ ```ruby
81
+ class ProfileForm < Axn::FormObject
82
+ validates :name, presence: true
83
+ validates :bio, length: { maximum: 500 }
84
+ end
85
+
86
+ form = ProfileForm.new(name: "Alice", bio: "Developer")
87
+ form.to_h # => { name: "Alice", bio: "Developer" }
88
+ ```
89
+
90
+ This is particularly useful when creating or updating records:
91
+
92
+ ```ruby
93
+ class UpdateProfile
94
+ include Axn
95
+
96
+ use :form, type: ProfileForm
97
+
98
+ expects :user, model: User
99
+
100
+ def call
101
+ user.update!(form.to_h)
102
+ end
103
+ end
104
+ ```
105
+
106
+ ## Nested Forms
107
+
108
+ Use `nested_forms` (or `nested_form`) to declare child form objects:
109
+
110
+ ```ruby
111
+ class OrderForm < Axn::FormObject
112
+ validates :customer_email, presence: true
113
+
114
+ nested_form shipping_address: AddressForm
115
+ nested_form billing_address: AddressForm
116
+ end
117
+
118
+ class AddressForm < Axn::FormObject
119
+ validates :street, presence: true
120
+ validates :city, presence: true
121
+ validates :zip, presence: true
122
+ end
123
+ ```
124
+
125
+ ### Nested Form Behavior
126
+
127
+ - Nested forms are validated when the parent is validated
128
+ - Child errors are bubbled up with prefixed attribute names
129
+ - The child form receives a `parent_form` accessor if it defines one
130
+
131
+ ```ruby
132
+ form = OrderForm.new(
133
+ customer_email: "alice@example.com",
134
+ shipping_address: { street: "123 Main St", city: "Boston", zip: "02101" },
135
+ billing_address: { street: "", city: "", zip: "" } # Invalid
136
+ )
137
+
138
+ form.valid? # => false
139
+ form.errors.full_messages
140
+ # => ["Billing address.street can't be blank", "Billing address.city can't be blank", ...]
141
+ ```
142
+
143
+ ### Accessing Parent Form
144
+
145
+ Child forms can access their parent:
146
+
147
+ ```ruby
148
+ class LineItemForm < Axn::FormObject
149
+ attr_accessor :parent_form # Will be set automatically
150
+
151
+ validates :quantity, presence: true, numericality: { greater_than: 0 }
152
+
153
+ validate :quantity_available
154
+
155
+ private
156
+
157
+ def quantity_available
158
+ return unless parent_form&.product
159
+
160
+ max = parent_form.product.stock_quantity
161
+ errors.add(:quantity, "exceeds available stock (#{max})") if quantity > max
162
+ end
163
+ end
164
+ ```
165
+
166
+ ## Inheritance
167
+
168
+ Form objects support inheritance, and field names are inherited:
169
+
170
+ ```ruby
171
+ class BaseForm < Axn::FormObject
172
+ validates :created_by, presence: true
173
+ end
174
+
175
+ class UserForm < BaseForm
176
+ validates :email, presence: true
177
+ validates :name, presence: true
178
+ end
179
+
180
+ UserForm.field_names # => [:created_by, :email, :name]
181
+ ```
182
+
183
+ ## Integration with Actions
184
+
185
+ Form objects are designed to work seamlessly with the [Form Strategy](/strategies/form):
186
+
187
+ ```ruby
188
+ class CreateUser
189
+ include Axn
190
+
191
+ use :form, type: UserForm
192
+
193
+ exposes :user
194
+
195
+ error { form.errors.full_messages.to_sentence }
196
+ success { "Welcome, #{user.name}!" }
197
+
198
+ def call
199
+ user = User.create!(form.to_h)
200
+ expose user: user
201
+ end
202
+ end
203
+
204
+ class UserForm < Axn::FormObject
205
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
206
+ validates :name, presence: true
207
+ validates :password, presence: true, length: { minimum: 8 }
208
+ end
209
+ ```
210
+
211
+ ## Complete Example
212
+
213
+ ```ruby
214
+ class CompanyRegistrationForm < Axn::FormObject
215
+ validates :company_name, presence: true
216
+ validates :industry, presence: true, inclusion: { in: %w[tech finance healthcare retail] }
217
+
218
+ nested_form admin: AdminForm
219
+ nested_form billing: BillingForm
220
+
221
+ def industry_options
222
+ [
223
+ ["Technology", "tech"],
224
+ ["Finance", "finance"],
225
+ ["Healthcare", "healthcare"],
226
+ ["Retail", "retail"]
227
+ ]
228
+ end
229
+ end
230
+
231
+ class AdminForm < Axn::FormObject
232
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
233
+ validates :name, presence: true
234
+ validates :password, presence: true, length: { minimum: 8 }
235
+ end
236
+
237
+ class BillingForm < Axn::FormObject
238
+ attr_accessor :parent_form
239
+
240
+ validates :billing_email, presence: true
241
+ validates :payment_method, presence: true, inclusion: { in: %w[card ach invoice] }
242
+
243
+ def billing_email
244
+ @billing_email.presence || parent_form&.admin&.email
245
+ end
246
+ end
247
+ ```
248
+
249
+ ## See Also
250
+
251
+ - [Form Strategy](/strategies/form) - Using form objects with actions
252
+ - [Validating User Input](/recipes/validating-user-input) - When to use form objects
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## `#expose`
4
4
 
5
- Used to set a value on the Action::Result. Remember you can only `expose` keys that you have declared in [the class-level interface](/reference/class).
5
+ Used to set a value on the Axn::Result. Remember you can only `expose` keys that you have declared in [the class-level interface](/reference/class).
6
6
 
7
7
  * Accepts two positional arguments (the key and value to set, respectively): `expose :some_key, 123`
8
8
  * Accepts a hash with one or more key/value pairs: `expose some_key: 123, another: 456`
@@ -12,43 +12,28 @@ 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 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. Can also accept keyword arguments that will be exposed before halting execution.
16
16
 
17
- ## `#log`
18
-
19
- Helper method to log (via the [configurable](/reference/configuration#logger) `Action.config.logger`) the string you provide (prefixed with the Action's class name).
20
-
21
- * First argument (required) is a string message to log
22
- * Also accepts a `level:` keyword argument to change the log level (defaults to `info`)
17
+ * First argument (optional) is a string error message
18
+ * Additional keyword arguments are exposed as data before halting
23
19
 
24
- Primarily used for its side effects; returns whatever the underlying `Action.config.logger` instance returns but it does return a Hash with the key/value pair(s) you exposed.
20
+ ## `#done!`
25
21
 
26
- ## `#try`
22
+ Called with an optional string, it immediately halts execution and sets `result.success` to the provided string (or default success message if none provided). Can also accept keyword arguments that will be exposed before halting execution. Skips `after` hooks and remaining `call` method execution, but allows `around` hooks to complete normally.
27
23
 
28
- Accepts a block. Any exceptions raised within that block will be swallowed, but _they will NOT fail the action_!
24
+ * First argument (optional) is a string success message
25
+ * Additional keyword arguments are exposed as data before halting
29
26
 
30
- A few details:
31
- * An explicit `fail!` call _will_ still fail the action
32
- * Any exceptions swallowed _will_ still be reported via the `on_exception` handler
27
+ **Important:** This method is implemented internally via an exception, so it will roll back manually applied `ActiveRecord::Base.transaction` blocks. Use the [`use :transaction` strategy](/strategies/transaction) instead for transaction-safe early completion.
33
28
 
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
-
36
- Example:
37
-
38
- ```ruby
39
- class Foo
40
- include Action
29
+ ## `#log`
41
30
 
42
- after do
43
- try { send_slack_notifications } # [!code focus]
44
- end
31
+ Helper method to log (via the [configurable](/reference/configuration#logger) `Axn.config.logger`) the string you provide (prefixed with the Action's class name).
45
32
 
46
- def call = ...
33
+ * First argument (required) is a string message to log
34
+ * Also accepts a `level:` keyword argument to change the log level (defaults to `info`)
47
35
 
48
- private
36
+ Primarily used for its side effects; returns whatever the underlying `Axn.config.logger` instance returns but it does return a Hash with the key/value pair(s) you exposed.
49
37
 
50
- def send_slack_notifications = ...
51
- end
52
- ```
53
38
 
54
39
 
@@ -0,0 +1,212 @@
1
+ # Client Strategy
2
+
3
+ The `client` strategy provides a declarative way to configure HTTP clients for API integrations. It creates a memoized Faraday connection with sensible defaults and optional error handling.
4
+
5
+ ::: warning Peer Dependency
6
+ This strategy requires the `faraday` gem to be available. It is only registered when Faraday is loaded.
7
+ :::
8
+
9
+ ## Basic Usage
10
+
11
+ ```ruby
12
+ class FetchUserData
13
+ include Axn
14
+
15
+ use :client, url: "https://api.example.com"
16
+
17
+ expects :user_id
18
+ exposes :user_data
19
+
20
+ def call
21
+ response = client.get("/users/#{user_id}")
22
+ expose user_data: response.body
23
+ end
24
+ end
25
+ ```
26
+
27
+ ## Configuration Options
28
+
29
+ | Option | Default | Description |
30
+ | ------ | ------- | ----------- |
31
+ | `name` | `:client` | The method name for accessing the client |
32
+ | `url` | (required) | Base URL for the API |
33
+ | `headers` | `{}` | Default headers to include in all requests |
34
+ | `user_agent` | Auto-generated | Custom User-Agent header |
35
+ | `debug` | `false` | Enable Faraday response logging |
36
+ | `prepend_config` | `nil` | Proc to prepend middleware configuration |
37
+ | `error_handler` | `nil` | Error handling configuration (see below) |
38
+
39
+ Any additional options are passed directly to `Faraday.new`.
40
+
41
+ ## Default Middleware
42
+
43
+ The client strategy automatically configures these middleware:
44
+
45
+ 1. `Content-Type: application/json` header
46
+ 2. `User-Agent` header (configurable)
47
+ 3. `response :raise_error` - Raises on 4xx/5xx responses
48
+ 4. `request :url_encoded` - Encodes request parameters
49
+ 5. `request :json` - JSON request encoding
50
+ 6. `response :json` - JSON response parsing
51
+
52
+ ## Custom Client Name
53
+
54
+ ```ruby
55
+ class ExternalApiAction
56
+ include Axn
57
+
58
+ use :client, name: :api_client, url: "https://api.example.com"
59
+ use :client, name: :auth_client, url: "https://auth.example.com"
60
+
61
+ def call
62
+ token = auth_client.post("/token").body["access_token"]
63
+ data = api_client.get("/data", nil, { "Authorization" => "Bearer #{token}" })
64
+ # ...
65
+ end
66
+ end
67
+ ```
68
+
69
+ ## Dynamic Configuration
70
+
71
+ Options can be callables (procs/lambdas) for dynamic values:
72
+
73
+ ```ruby
74
+ class SecureApiAction
75
+ include Axn
76
+
77
+ use :client,
78
+ url: "https://api.example.com",
79
+ headers: -> { { "Authorization" => "Bearer #{current_token}" } }
80
+
81
+ private
82
+
83
+ def current_token
84
+ # Fetch or refresh token as needed
85
+ TokenStore.get_valid_token
86
+ end
87
+ end
88
+ ```
89
+
90
+ ## Custom Headers
91
+
92
+ ```ruby
93
+ class ApiAction
94
+ include Axn
95
+
96
+ use :client,
97
+ url: "https://api.example.com",
98
+ headers: {
99
+ "X-API-Key" => ENV["API_KEY"],
100
+ "Accept" => "application/json"
101
+ }
102
+ end
103
+ ```
104
+
105
+ ## Error Handling
106
+
107
+ The `error_handler` option configures custom error handling middleware:
108
+
109
+ ```ruby
110
+ class ApiAction
111
+ include Axn
112
+
113
+ use :client,
114
+ url: "https://api.example.com",
115
+ error_handler: {
116
+ if: -> { status != 200 }, # Condition to trigger error handling
117
+ error_key: "error.message", # JSON path to error message
118
+ detail_key: "error.details", # JSON path to error details (optional)
119
+ backtrace_key: "error.backtrace", # JSON path to backtrace (optional)
120
+ exception_class: CustomApiError, # Exception class to raise (default: Faraday::BadRequestError)
121
+ formatter: ->(error, details, env) { # Custom message formatter (optional)
122
+ "API Error: #{error} - #{details}"
123
+ },
124
+ extract_detail: ->(key, value) { # Extract detail from hash/array (optional)
125
+ "#{key}: #{value}"
126
+ }
127
+ }
128
+ end
129
+ ```
130
+
131
+ ### Error Handler Options
132
+
133
+ | Option | Description |
134
+ | ------ | ----------- |
135
+ | `if` | Condition proc to trigger error handling (receives `status`, `body`, `response_env`) |
136
+ | `error_key` | Dot-notation path to error message in response JSON |
137
+ | `detail_key` | Dot-notation path to error details |
138
+ | `backtrace_key` | Dot-notation path to backtrace |
139
+ | `exception_class` | Exception class to raise (default: `Faraday::BadRequestError`) |
140
+ | `formatter` | Custom proc to format the error message |
141
+ | `extract_detail` | Proc to extract details from nested structures |
142
+
143
+ ## Prepending Middleware
144
+
145
+ Use `prepend_config` when you need to add middleware before the default stack:
146
+
147
+ ```ruby
148
+ class ApiAction
149
+ include Axn
150
+
151
+ use :client,
152
+ url: "https://api.example.com",
153
+ prepend_config: ->(conn) {
154
+ conn.request :retry, max: 3, interval: 0.5
155
+ conn.request :authorization, "Bearer", -> { fetch_token }
156
+ }
157
+ end
158
+ ```
159
+
160
+ ## Complete Example
161
+
162
+ ```ruby
163
+ class SyncExternalData
164
+ include Axn
165
+
166
+ use :client,
167
+ name: :external_api,
168
+ url: ENV["EXTERNAL_API_URL"],
169
+ headers: -> { { "Authorization" => "Bearer #{api_token}" } },
170
+ user_agent: "MyApp/1.0",
171
+ error_handler: {
172
+ error_key: "error.message",
173
+ detail_key: "error.details",
174
+ extract_detail: ->(node) { node["field"] ? "#{node['field']}: #{node['message']}" : node["message"] }
175
+ }
176
+
177
+ expects :company, model: Company
178
+ exposes :synced_records
179
+
180
+ error "Failed to sync external data"
181
+ error from: Faraday::BadRequestError do |e|
182
+ "External API error: #{e.message}"
183
+ end
184
+
185
+ def call
186
+ response = external_api.get("/companies/#{company.external_id}/data")
187
+ records = response.body["records"].map do |record|
188
+ company.external_records.find_or_create_by!(external_id: record["id"]) do |r|
189
+ r.data = record
190
+ end
191
+ end
192
+ expose synced_records: records
193
+ end
194
+
195
+ private
196
+
197
+ def api_token
198
+ Rails.cache.fetch("external_api_token", expires_in: 1.hour) do
199
+ # Token refresh logic
200
+ end
201
+ end
202
+ end
203
+ ```
204
+
205
+ ## Memoization
206
+
207
+ The client is automatically memoized using `memo`, so repeated calls to the client method return the same Faraday connection instance. This ensures efficient connection reuse within a single action execution.
208
+
209
+ ## See Also
210
+
211
+ - [Strategies Overview](/strategies/index) - How to use and create strategies
212
+ - [Memoization](/recipes/memoization) - How memoization works in Axn