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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/CHANGELOG.md +15 -1
  4. data/Rakefile +102 -2
  5. data/docs/.vitepress/config.mjs +12 -8
  6. data/docs/advanced/conventions.md +1 -1
  7. data/docs/advanced/mountable.md +4 -90
  8. data/docs/advanced/profiling.md +26 -30
  9. data/docs/advanced/rough.md +27 -8
  10. data/docs/intro/overview.md +1 -1
  11. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  12. data/docs/recipes/memoization.md +102 -17
  13. data/docs/reference/async.md +269 -0
  14. data/docs/reference/class.md +113 -50
  15. data/docs/reference/configuration.md +226 -75
  16. data/docs/reference/form-object.md +252 -0
  17. data/docs/strategies/client.md +212 -0
  18. data/docs/strategies/form.md +235 -0
  19. data/docs/usage/setup.md +2 -2
  20. data/docs/usage/writing.md +99 -1
  21. data/lib/axn/async/adapters/active_job.rb +19 -10
  22. data/lib/axn/async/adapters/disabled.rb +15 -0
  23. data/lib/axn/async/adapters/sidekiq.rb +25 -32
  24. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  25. data/lib/axn/async/batch_enqueue.rb +99 -0
  26. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  27. data/lib/axn/async.rb +121 -4
  28. data/lib/axn/configuration.rb +53 -13
  29. data/lib/axn/context.rb +1 -0
  30. data/lib/axn/core/automatic_logging.rb +47 -51
  31. data/lib/axn/core/context/facade_inspector.rb +1 -1
  32. data/lib/axn/core/contract.rb +73 -30
  33. data/lib/axn/core/contract_for_subfields.rb +1 -1
  34. data/lib/axn/core/contract_validation.rb +14 -9
  35. data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
  36. data/lib/axn/core/default_call.rb +63 -0
  37. data/lib/axn/core/flow/exception_execution.rb +5 -0
  38. data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
  39. data/lib/axn/core/flow/handlers/invoker.rb +4 -30
  40. data/lib/axn/core/flow/handlers/matcher.rb +4 -14
  41. data/lib/axn/core/flow/messages.rb +1 -1
  42. data/lib/axn/core/hooks.rb +1 -0
  43. data/lib/axn/core/logging.rb +16 -5
  44. data/lib/axn/core/memoization.rb +53 -0
  45. data/lib/axn/core/tracing.rb +77 -4
  46. data/lib/axn/core/validation/validators/type_validator.rb +1 -1
  47. data/lib/axn/core.rb +31 -46
  48. data/lib/axn/extras/strategies/client.rb +150 -0
  49. data/lib/axn/extras/strategies/vernier.rb +121 -0
  50. data/lib/axn/extras.rb +4 -0
  51. data/lib/axn/factory.rb +22 -2
  52. data/lib/axn/form_object.rb +90 -0
  53. data/lib/axn/internal/logging.rb +5 -1
  54. data/lib/axn/mountable/helpers/class_builder.rb +41 -10
  55. data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
  56. data/lib/axn/mountable/inherit_profiles.rb +2 -2
  57. data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
  58. data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
  59. data/lib/axn/mountable.rb +41 -7
  60. data/lib/axn/rails/generators/axn_generator.rb +19 -1
  61. data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
  62. data/lib/axn/result.rb +2 -2
  63. data/lib/axn/strategies/form.rb +98 -0
  64. data/lib/axn/strategies/transaction.rb +7 -0
  65. data/lib/axn/util/callable.rb +120 -0
  66. data/lib/axn/util/contract_error_handling.rb +32 -0
  67. data/lib/axn/util/execution_context.rb +34 -0
  68. data/lib/axn/util/global_id_serialization.rb +52 -0
  69. data/lib/axn/util/logging.rb +87 -0
  70. data/lib/axn/version.rb +1 -1
  71. data/lib/axn.rb +9 -0
  72. metadata +22 -4
  73. data/lib/axn/core/profiling.rb +0 -124
  74. 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 Action`.
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-and-metrics).
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