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.
- checksums.yaml +4 -4
- data/.cursor/commands/pr.md +36 -0
- data/.cursor/rules/axn-framework-patterns.mdc +43 -0
- data/.cursor/rules/general-coding-standards.mdc +27 -0
- data/.cursor/rules/spec/testing-patterns.mdc +40 -0
- data/CHANGELOG.md +57 -0
- data/Rakefile +114 -4
- data/docs/.vitepress/config.mjs +19 -10
- data/docs/advanced/conventions.md +3 -3
- data/docs/advanced/mountable.md +476 -0
- data/docs/advanced/profiling.md +351 -0
- data/docs/advanced/rough.md +27 -8
- data/docs/index.md +5 -3
- data/docs/intro/about.md +1 -1
- data/docs/intro/overview.md +6 -6
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +103 -18
- data/docs/recipes/rubocop-integration.md +38 -284
- data/docs/recipes/testing.md +14 -14
- data/docs/recipes/validating-user-input.md +1 -1
- data/docs/reference/async.md +429 -0
- data/docs/reference/axn-result.md +107 -0
- data/docs/reference/class.md +225 -64
- data/docs/reference/configuration.md +366 -34
- data/docs/reference/form-object.md +252 -0
- data/docs/reference/instance.md +14 -29
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/strategies/index.md +21 -21
- data/docs/strategies/transaction.md +1 -1
- data/docs/usage/setup.md +16 -2
- data/docs/usage/steps.md +7 -7
- data/docs/usage/using.md +23 -12
- data/docs/usage/writing.md +191 -12
- data/lib/axn/async/adapters/active_job.rb +74 -0
- data/lib/axn/async/adapters/disabled.rb +41 -0
- data/lib/axn/async/adapters/sidekiq.rb +67 -0
- data/lib/axn/async/adapters.rb +26 -0
- data/lib/axn/async/batch_enqueue/config.rb +38 -0
- data/lib/axn/async/batch_enqueue.rb +99 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
- data/lib/axn/async.rb +178 -0
- data/lib/axn/configuration.rb +113 -0
- data/lib/{action → axn}/context.rb +22 -4
- data/lib/axn/core/automatic_logging.rb +89 -0
- data/lib/axn/core/context/facade.rb +69 -0
- data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
- data/lib/{action → axn}/core/context/internal.rb +5 -5
- data/lib/{action → axn}/core/contract.rb +111 -73
- data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
- data/lib/{action → axn}/core/contract_validation.rb +27 -12
- data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/field_resolvers/extract.rb +32 -0
- data/lib/axn/core/field_resolvers/model.rb +63 -0
- data/lib/axn/core/field_resolvers.rb +24 -0
- data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
- data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
- data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
- data/lib/axn/core/flow/handlers/invoker.rb +47 -0
- data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
- data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
- data/lib/axn/core/flow/handlers.rb +20 -0
- data/lib/{action → axn}/core/flow/messages.rb +8 -8
- data/lib/{action → axn}/core/flow.rb +4 -4
- data/lib/{action → axn}/core/hooks.rb +17 -5
- data/lib/axn/core/logging.rb +48 -0
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
- data/lib/{action → axn}/core/timing.rb +1 -1
- data/lib/axn/core/tracing.rb +90 -0
- data/lib/axn/core/use_strategy.rb +29 -0
- data/lib/{action → axn}/core/validation/fields.rb +26 -2
- data/lib/{action → axn}/core/validation/subfields.rb +14 -12
- data/lib/axn/core/validation/validators/model_validator.rb +36 -0
- data/lib/axn/core/validation/validators/type_validator.rb +80 -0
- data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
- data/lib/{action → axn}/core.rb +55 -55
- data/lib/{action → axn}/exceptions.rb +12 -2
- data/lib/axn/extras/strategies/client.rb +150 -0
- data/lib/axn/extras/strategies/vernier.rb +121 -0
- data/lib/axn/extras.rb +4 -0
- data/lib/axn/factory.rb +122 -34
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +30 -0
- data/lib/axn/internal/registry.rb +87 -0
- data/lib/axn/mountable/descriptor.rb +76 -0
- data/lib/axn/mountable/helpers/class_builder.rb +193 -0
- data/lib/axn/mountable/helpers/mounter.rb +33 -0
- data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
- data/lib/axn/mountable/helpers/validator.rb +112 -0
- data/lib/axn/mountable/inherit_profiles.rb +72 -0
- data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
- data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
- data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
- data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
- data/lib/axn/mountable/mounting_strategies.rb +32 -0
- data/lib/axn/mountable.rb +119 -0
- data/lib/axn/rails/engine.rb +51 -0
- data/lib/axn/rails/generators/axn_generator.rb +86 -0
- data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
- data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
- data/lib/{action → axn}/result.rb +32 -13
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +26 -0
- data/lib/axn/strategies.rb +20 -0
- data/lib/axn/testing/spec_helpers.rb +6 -8
- data/lib/axn/util/callable.rb +120 -0
- data/lib/axn/util/contract_error_handling.rb +32 -0
- data/lib/axn/util/execution_context.rb +34 -0
- data/lib/axn/util/global_id_serialization.rb +52 -0
- data/lib/axn/util/logging.rb +87 -0
- data/lib/axn/util/memoization.rb +20 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +26 -16
- data/lib/rubocop/cop/axn/README.md +23 -23
- data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
- metadata +106 -64
- data/.rspec +0 -3
- data/.rubocop.yml +0 -76
- data/.tool-versions +0 -1
- data/docs/reference/action-result.md +0 -37
- data/lib/action/attachable/base.rb +0 -43
- data/lib/action/attachable/steps.rb +0 -63
- data/lib/action/attachable/subactions.rb +0 -70
- data/lib/action/attachable.rb +0 -17
- data/lib/action/configuration.rb +0 -55
- data/lib/action/core/automatic_logging.rb +0 -93
- data/lib/action/core/context/facade.rb +0 -48
- data/lib/action/core/flow/handlers/invoker.rb +0 -73
- data/lib/action/core/flow/handlers.rb +0 -20
- data/lib/action/core/logging.rb +0 -37
- data/lib/action/core/tracing.rb +0 -17
- data/lib/action/core/use_strategy.rb +0 -30
- data/lib/action/core/validation/validators/model_validator.rb +0 -34
- data/lib/action/core/validation/validators/type_validator.rb +0 -30
- data/lib/action/enqueueable/via_sidekiq.rb +0 -76
- data/lib/action/enqueueable.rb +0 -13
- data/lib/action/strategies/transaction.rb +0 -19
- data/lib/action/strategies.rb +0 -48
- data/lib/axn/util.rb +0 -24
- data/package.json +0 -10
- 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
|
data/docs/reference/instance.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## `#expose`
|
|
4
4
|
|
|
5
|
-
Used to set a value on the
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
## `#done!`
|
|
25
21
|
|
|
26
|
-
|
|
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
|
-
|
|
24
|
+
* First argument (optional) is a string success message
|
|
25
|
+
* Additional keyword arguments are exposed as data before halting
|
|
29
26
|
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
Example:
|
|
37
|
-
|
|
38
|
-
```ruby
|
|
39
|
-
class Foo
|
|
40
|
-
include Action
|
|
29
|
+
## `#log`
|
|
41
30
|
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|