axn 0.1.0.pre.alpha.3 → 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/CHANGELOG.md +15 -1
- data/Rakefile +102 -2
- data/docs/.vitepress/config.mjs +12 -8
- data/docs/advanced/conventions.md +1 -1
- data/docs/advanced/mountable.md +4 -90
- data/docs/advanced/profiling.md +26 -30
- data/docs/advanced/rough.md +27 -8
- data/docs/intro/overview.md +1 -1
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +102 -17
- data/docs/reference/async.md +269 -0
- data/docs/reference/class.md +113 -50
- data/docs/reference/configuration.md +226 -75
- data/docs/reference/form-object.md +252 -0
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/usage/setup.md +2 -2
- data/docs/usage/writing.md +99 -1
- data/lib/axn/async/adapters/active_job.rb +19 -10
- data/lib/axn/async/adapters/disabled.rb +15 -0
- data/lib/axn/async/adapters/sidekiq.rb +25 -32
- 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 +121 -4
- data/lib/axn/configuration.rb +53 -13
- data/lib/axn/context.rb +1 -0
- data/lib/axn/core/automatic_logging.rb +47 -51
- data/lib/axn/core/context/facade_inspector.rb +1 -1
- data/lib/axn/core/contract.rb +73 -30
- data/lib/axn/core/contract_for_subfields.rb +1 -1
- data/lib/axn/core/contract_validation.rb +14 -9
- data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/flow/exception_execution.rb +5 -0
- data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
- data/lib/axn/core/flow/handlers/invoker.rb +4 -30
- data/lib/axn/core/flow/handlers/matcher.rb +4 -14
- data/lib/axn/core/flow/messages.rb +1 -1
- data/lib/axn/core/hooks.rb +1 -0
- data/lib/axn/core/logging.rb +16 -5
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/axn/core/tracing.rb +77 -4
- data/lib/axn/core/validation/validators/type_validator.rb +1 -1
- data/lib/axn/core.rb +31 -46
- 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 +22 -2
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +5 -1
- data/lib/axn/mountable/helpers/class_builder.rb +41 -10
- data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
- data/lib/axn/mountable/inherit_profiles.rb +2 -2
- data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
- data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
- data/lib/axn/mountable.rb +41 -7
- data/lib/axn/rails/generators/axn_generator.rb +19 -1
- data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
- data/lib/axn/result.rb +2 -2
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +7 -0
- 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/version.rb +1 -1
- data/lib/axn.rb +9 -0
- metadata +22 -4
- data/lib/axn/core/profiling.rb +0 -124
- data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# Form Strategy
|
|
2
|
+
|
|
3
|
+
The `form` strategy provides a declarative way to validate user input using form objects. It bridges the gap between raw user input (like `params`) and validated, structured data.
|
|
4
|
+
|
|
5
|
+
::: tip When to Use
|
|
6
|
+
Use the form strategy when you need to validate **user-facing input** with user-friendly error messages. This is different from `expects` validations, which validate the **developer contract** (how the action is called).
|
|
7
|
+
:::
|
|
8
|
+
|
|
9
|
+
## Basic Usage
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class CreateUser
|
|
13
|
+
include Axn
|
|
14
|
+
|
|
15
|
+
use :form, type: CreateUser::Form
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
# form is automatically validated and exposed
|
|
19
|
+
# If validation fails, the action fails with form.errors
|
|
20
|
+
User.create!(form.to_h)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class CreateUser::Form < Axn::FormObject
|
|
25
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
26
|
+
validates :name, presence: true, length: { minimum: 2 }
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration Options
|
|
31
|
+
|
|
32
|
+
The form strategy accepts several configuration options:
|
|
33
|
+
|
|
34
|
+
| Option | Default | Description |
|
|
35
|
+
| ------ | ------- | ----------- |
|
|
36
|
+
| `type` | Auto-detected | The form class to use (see [Type Resolution](#type-resolution)) |
|
|
37
|
+
| `expect` | `:params` | The input field name to read from |
|
|
38
|
+
| `expose` | `:form` | The field name to expose the form object as |
|
|
39
|
+
| `inject` | `nil` | Additional context fields to inject into the form |
|
|
40
|
+
|
|
41
|
+
### Type Resolution
|
|
42
|
+
|
|
43
|
+
The `type` option determines which form class to use:
|
|
44
|
+
|
|
45
|
+
1. **Explicit class**: `use :form, type: MyFormClass`
|
|
46
|
+
2. **String constant path**: `use :form, type: "CreateUser::Form"`
|
|
47
|
+
3. **Auto-detected**: If not specified, inferred from action name + expose name (e.g., `CreateUser` + `:form` → `CreateUser::Form`)
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Explicit type
|
|
51
|
+
use :form, type: RegistrationForm
|
|
52
|
+
|
|
53
|
+
# String constant (useful for avoiding load order issues)
|
|
54
|
+
use :form, type: "Users::RegistrationForm"
|
|
55
|
+
|
|
56
|
+
# Auto-detected from action name
|
|
57
|
+
class CreateUser
|
|
58
|
+
include Axn
|
|
59
|
+
use :form # Uses CreateUser::Form
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Inline Form Definition
|
|
64
|
+
|
|
65
|
+
You can define the form class inline using a block:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class CreateUser
|
|
69
|
+
include Axn
|
|
70
|
+
|
|
71
|
+
use :form do
|
|
72
|
+
validates :email, presence: true
|
|
73
|
+
validates :name, presence: true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call
|
|
77
|
+
User.create!(form.to_h)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This creates an anonymous form class that inherits from `Axn::FormObject`.
|
|
83
|
+
|
|
84
|
+
### Custom Field Names
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class ProcessOrder
|
|
88
|
+
include Axn
|
|
89
|
+
|
|
90
|
+
# Read from :order_params, expose as :order_form
|
|
91
|
+
use :form, expect: :order_params, expose: :order_form, type: OrderForm
|
|
92
|
+
|
|
93
|
+
def call
|
|
94
|
+
# Access via order_form instead of form
|
|
95
|
+
Order.create!(order_form.to_h)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Injecting Context
|
|
101
|
+
|
|
102
|
+
Use `inject` to pass additional context fields to the form:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class UpdateProfile
|
|
106
|
+
include Axn
|
|
107
|
+
|
|
108
|
+
expects :user, model: User
|
|
109
|
+
use :form, type: ProfileForm, inject: [:user]
|
|
110
|
+
|
|
111
|
+
def call
|
|
112
|
+
user.update!(form.to_h)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class ProfileForm < Axn::FormObject
|
|
117
|
+
attr_accessor :user # Injected from action context
|
|
118
|
+
|
|
119
|
+
validates :email, presence: true
|
|
120
|
+
validate :email_unique_for_other_users
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def email_unique_for_other_users
|
|
125
|
+
return if user.nil?
|
|
126
|
+
return unless User.where.not(id: user.id).exists?(email: email)
|
|
127
|
+
|
|
128
|
+
errors.add(:email, "is already taken")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## How It Works
|
|
134
|
+
|
|
135
|
+
When you use the form strategy, the following happens automatically:
|
|
136
|
+
|
|
137
|
+
1. **Expects params**: Adds `expects :params, type: :params` (or your custom `expect` field)
|
|
138
|
+
2. **Exposes form**: Adds `exposes :form` (or your custom `expose` field)
|
|
139
|
+
3. **Creates form**: Defines a memoized method that creates the form from params
|
|
140
|
+
4. **Validates in before hook**: Runs `form.valid?` in a before hook; if invalid, the action fails
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# This:
|
|
144
|
+
use :form, type: MyForm
|
|
145
|
+
|
|
146
|
+
# Is roughly equivalent to:
|
|
147
|
+
expects :params, type: :params
|
|
148
|
+
exposes :form, type: MyForm
|
|
149
|
+
|
|
150
|
+
def form
|
|
151
|
+
@form ||= MyForm.new(params)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
before do
|
|
155
|
+
expose form: form
|
|
156
|
+
fail! unless form.valid?
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Error Handling
|
|
161
|
+
|
|
162
|
+
When form validation fails:
|
|
163
|
+
- The action fails (returns `ok? == false`)
|
|
164
|
+
- `result.error` contains a generic message
|
|
165
|
+
- `result.form.errors` contains the detailed validation errors
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
result = CreateUser.call(params: { email: "", name: "" })
|
|
169
|
+
|
|
170
|
+
result.ok? # => false
|
|
171
|
+
result.form.errors.full_messages
|
|
172
|
+
# => ["Email can't be blank", "Name can't be blank"]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### User-Facing Errors
|
|
176
|
+
|
|
177
|
+
To expose user-friendly error messages, configure a custom error handler:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
class CreateUser
|
|
181
|
+
include Axn
|
|
182
|
+
|
|
183
|
+
use :form, type: CreateUser::Form
|
|
184
|
+
|
|
185
|
+
error { form.errors.full_messages.to_sentence }
|
|
186
|
+
|
|
187
|
+
def call
|
|
188
|
+
User.create!(form.to_h)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Complete Example
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class CreateCompanyMember
|
|
197
|
+
include Axn
|
|
198
|
+
|
|
199
|
+
expects :company, model: Company
|
|
200
|
+
use :form, type: MemberForm, inject: [:company]
|
|
201
|
+
|
|
202
|
+
exposes :member
|
|
203
|
+
|
|
204
|
+
error { form.errors.full_messages.to_sentence }
|
|
205
|
+
success { "#{member.name} has been added to #{company.name}" }
|
|
206
|
+
|
|
207
|
+
def call
|
|
208
|
+
member = company.members.create!(form.to_h)
|
|
209
|
+
expose member: member
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
class MemberForm < Axn::FormObject
|
|
214
|
+
attr_accessor :company # Injected
|
|
215
|
+
|
|
216
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
217
|
+
validates :name, presence: true
|
|
218
|
+
validates :role, presence: true, inclusion: { in: %w[admin member guest] }
|
|
219
|
+
|
|
220
|
+
validate :email_not_already_member
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def email_not_already_member
|
|
225
|
+
return unless company&.members&.exists?(email: email)
|
|
226
|
+
|
|
227
|
+
errors.add(:email, "is already a member of this company")
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## See Also
|
|
233
|
+
|
|
234
|
+
- [Axn::FormObject](/reference/form-object) - The base class for form objects
|
|
235
|
+
- [Validating User Input](/recipes/validating-user-input) - When to use form validation vs expects validation
|
data/docs/usage/setup.md
CHANGED
|
@@ -5,7 +5,7 @@ outline: deep
|
|
|
5
5
|
|
|
6
6
|
## Installation
|
|
7
7
|
|
|
8
|
-
Adding `axn` to your Gemfile is enough to start using `include
|
|
8
|
+
Adding `axn` to your Gemfile is enough to start using `include Axn`.
|
|
9
9
|
|
|
10
10
|
## Global Configuration
|
|
11
11
|
|
|
@@ -19,7 +19,7 @@ By default any swallowed errors are noted in the logs, but it's _highly recommen
|
|
|
19
19
|
|
|
20
20
|
### Metrics / Tracing
|
|
21
21
|
|
|
22
|
-
If you're using an APM provider, observability can be greatly enhanced by [configuring tracing and metrics hooks](/reference/configuration#tracing
|
|
22
|
+
If you're using an APM provider, observability can be greatly enhanced by [configuring OpenTelemetry tracing and metrics hooks](/reference/configuration#opentelemetry-tracing). Axn automatically creates OpenTelemetry spans when OpenTelemetry is available.
|
|
23
23
|
|
|
24
24
|
### Rails Integration (Optional)
|
|
25
25
|
|