action_figure 0.1.0 → 0.5.0
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/LICENSE.txt +1 -1
- data/README.md +247 -16
- data/docs/actions.md +503 -0
- data/docs/activesupport-notifications.md +113 -0
- data/docs/configuration.md +88 -0
- data/docs/custom-formatters.md +175 -0
- data/docs/integration-patterns.md +331 -0
- data/docs/response-formatters.md +932 -0
- data/docs/testing.md +270 -0
- data/docs/validation.md +294 -0
- data/lib/action_figure/configuration.rb +33 -0
- data/lib/action_figure/core.rb +180 -0
- data/lib/action_figure/format_registry.rb +38 -0
- data/lib/action_figure/formatter.rb +14 -0
- data/lib/action_figure/formatters/default.rb +39 -0
- data/lib/action_figure/formatters/jsend.rb +41 -0
- data/lib/action_figure/formatters/json_api/resource.rb +32 -0
- data/lib/action_figure/formatters/json_api.rb +57 -0
- data/lib/action_figure/formatters/wrapped.rb +41 -0
- data/lib/action_figure/testing/minitest.rb +50 -0
- data/lib/action_figure/testing/rspec.rb +42 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +67 -0
- metadata +25 -5
data/docs/testing.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
ActionFigure actions return plain hashes, making them straightforward to test without controller setup or request scaffolding. You call the action directly, receive a result, and assert against it.
|
|
6
|
+
|
|
7
|
+
Both Minitest and RSpec helpers are provided. They wrap status checks in expressive, intention-revealing assertions so your tests read clearly.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Minitest
|
|
12
|
+
|
|
13
|
+
### Setup
|
|
14
|
+
|
|
15
|
+
Require the helper and include the module in your test class:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "action_figure/testing/minitest"
|
|
19
|
+
|
|
20
|
+
class Users::CreateActionTest < Minitest::Test
|
|
21
|
+
include ActionFigure::Testing::Minitest
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Assertions
|
|
26
|
+
|
|
27
|
+
| Assertion | Expected status |
|
|
28
|
+
|---------------------------------------|--------------------------|
|
|
29
|
+
| `assert_Ok(result)` | `:ok` |
|
|
30
|
+
| `assert_Created(result)` | `:created` |
|
|
31
|
+
| `assert_Accepted(result)` | `:accepted` |
|
|
32
|
+
| `assert_NoContent(result)` | `:no_content` |
|
|
33
|
+
| `assert_UnprocessableContent(result)` | `:unprocessable_content` |
|
|
34
|
+
| `assert_NotFound(result)` | `:not_found` |
|
|
35
|
+
| `assert_Forbidden(result)` | `:forbidden` |
|
|
36
|
+
|
|
37
|
+
All assertions accept an optional second argument for a custom failure message:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
assert_Ok(result, "expected the user to be created successfully")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
When a status assertion fails, the default message shows the expected and actual status:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Expected result status to be :ok, but got :unprocessable_content
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## RSpec
|
|
52
|
+
|
|
53
|
+
### Setup
|
|
54
|
+
|
|
55
|
+
Require the helper in your spec support file. No `include` is needed -- the matchers are registered globally:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# spec/spec_helper.rb
|
|
59
|
+
require "action_figure/testing/rspec"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Matchers
|
|
63
|
+
|
|
64
|
+
| Matcher | Expected status |
|
|
65
|
+
|---------------------------|--------------------------|
|
|
66
|
+
| `be_Ok` | `:ok` |
|
|
67
|
+
| `be_Created` | `:created` |
|
|
68
|
+
| `be_Accepted` | `:accepted` |
|
|
69
|
+
| `be_NoContent` | `:no_content` |
|
|
70
|
+
| `be_UnprocessableContent` | `:unprocessable_content` |
|
|
71
|
+
| `be_NotFound` | `:not_found` |
|
|
72
|
+
| `be_Forbidden` | `:forbidden` |
|
|
73
|
+
|
|
74
|
+
Matchers support negation:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
expect(result).to be_Ok
|
|
78
|
+
expect(result).not_to be_Forbidden
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Failure messages mirror the Minitest style:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
expected result status to be :ok, but got :unprocessable_content
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Testing Patterns
|
|
90
|
+
|
|
91
|
+
The examples below use Minitest, but the same patterns apply to RSpec with the corresponding matchers.
|
|
92
|
+
|
|
93
|
+
The examples below use the JSend formatter (`ActionFigure[:jsend]`) for consistency. The structure of `result[:json]` depends on your chosen formatter — see [Response Formatters](response-formatters.md) for the shape each format produces.
|
|
94
|
+
|
|
95
|
+
### Testing a Successful Action
|
|
96
|
+
|
|
97
|
+
Call your class and assert both the status and the returned data:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class Users::CreateActionTest < Minitest::Test
|
|
101
|
+
include ActionFigure::Testing::Minitest
|
|
102
|
+
|
|
103
|
+
def test_creates_a_user
|
|
104
|
+
result = Users::CreateAction.call(params: { email: "jane@example.com", name: "Jane" })
|
|
105
|
+
|
|
106
|
+
assert_Ok(result)
|
|
107
|
+
assert_equal "jane@example.com", result[:json][:data][:email]
|
|
108
|
+
assert_equal "Jane", result[:json][:data][:name]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Testing Validation Failure
|
|
114
|
+
|
|
115
|
+
When testing validation failures, assert both the status and the error message content. Testing only the status is insufficient -- it does not prove the correct validation failed.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class Users::CreateActionTest < Minitest::Test
|
|
119
|
+
include ActionFigure::Testing::Minitest
|
|
120
|
+
|
|
121
|
+
def test_rejects_missing_email
|
|
122
|
+
result = Users::CreateAction.call(params: { name: "Jane" })
|
|
123
|
+
|
|
124
|
+
assert_UnprocessableContent(result)
|
|
125
|
+
assert_includes result[:json][:data][:email], "is missing"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Testing with Context Injection
|
|
131
|
+
|
|
132
|
+
Actions often receive context such as `current_user:` as keyword arguments alongside `params:`. Pass them directly in the test:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class Posts::CreateActionTest < Minitest::Test
|
|
136
|
+
include ActionFigure::Testing::Minitest
|
|
137
|
+
|
|
138
|
+
def test_creates_a_post_for_the_current_user
|
|
139
|
+
user = users(:jane)
|
|
140
|
+
result = Posts::CreateAction.call(params: { title: "Hello", body: "World" }, current_user: user)
|
|
141
|
+
|
|
142
|
+
assert_Created(result)
|
|
143
|
+
assert_equal user.id, result[:json][:data][:author_id]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Testing a Custom Entry Point
|
|
149
|
+
|
|
150
|
+
When an action defines a custom class method (e.g., `.search`) instead of the default `.call`, call it by that name:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
class Products::SearchActionTest < Minitest::Test
|
|
154
|
+
include ActionFigure::Testing::Minitest
|
|
155
|
+
|
|
156
|
+
# class SearchAction
|
|
157
|
+
# include ActionFigure[:jsend]
|
|
158
|
+
#
|
|
159
|
+
# entry_point :search
|
|
160
|
+
#
|
|
161
|
+
# params_schema do
|
|
162
|
+
# required(:query).filled(:string)
|
|
163
|
+
# end
|
|
164
|
+
#
|
|
165
|
+
# def search(params:, **)
|
|
166
|
+
# products = Product.where("name ILIKE ?", "%#{params[:query]}%")
|
|
167
|
+
# Ok(resource: products)
|
|
168
|
+
# end
|
|
169
|
+
# end
|
|
170
|
+
def test_finds_matching_products
|
|
171
|
+
result = SearchAction.search(params: { query: "keyboard" })
|
|
172
|
+
|
|
173
|
+
assert_Ok(result)
|
|
174
|
+
assert result[:json][:data].any?, "expected at least one matching product"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Testing NoContent
|
|
180
|
+
|
|
181
|
+
Actions that perform side effects without returning data use `NoContent()`:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class Sessions::DestroyActionTest < Minitest::Test
|
|
185
|
+
include ActionFigure::Testing::Minitest
|
|
186
|
+
|
|
187
|
+
# class Sessions::DestroyAction
|
|
188
|
+
# include ActionFigure[:jsend]
|
|
189
|
+
#
|
|
190
|
+
# def call(session:)
|
|
191
|
+
# session.destroy!
|
|
192
|
+
# NoContent()
|
|
193
|
+
# end
|
|
194
|
+
# end
|
|
195
|
+
def test_destroys_the_session
|
|
196
|
+
session = sessions(:active)
|
|
197
|
+
result = Sessions::DestroyAction.call(session: session)
|
|
198
|
+
|
|
199
|
+
assert_NoContent(result)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Standalone Validation with `.contract`
|
|
207
|
+
|
|
208
|
+
Every action that defines a `params_schema` exposes the underlying validation contract via `.contract`. This returns a `Dry::Validation::Contract` instance that you can call directly -- useful for validating input without executing the action.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
contract = Users::CreateAction.contract
|
|
212
|
+
result = contract.call(email: "jane@example.com", name: "Jane")
|
|
213
|
+
|
|
214
|
+
result.success? # => true
|
|
215
|
+
result.to_h # => { email: "jane@example.com", name: "Jane" }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
When validation fails, inspect the errors:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
result = Users::CreateAction.contract.call(email: "", name: "Jane")
|
|
222
|
+
|
|
223
|
+
result.failure? # => true
|
|
224
|
+
result.errors.to_h # => { email: ["must be filled"] }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
This runs both the schema and any `rules` defined on the action -- the same validation pipeline that `.call` uses, without the side effects.
|
|
228
|
+
|
|
229
|
+
Actions that do not define a `params_schema` return `nil` from `.contract`.
|
|
230
|
+
|
|
231
|
+
### Inspecting schema and rules
|
|
232
|
+
|
|
233
|
+
The contract exposes the schema and rules for introspection:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
contract = Users::CreateAction.contract
|
|
237
|
+
|
|
238
|
+
contract.schema # => the Dry::Schema::Params instance
|
|
239
|
+
contract.schema.key_map.map(&:name) # => ["email", "name"]
|
|
240
|
+
|
|
241
|
+
contract.rules # => array of Dry::Validation::Rule objects
|
|
242
|
+
contract.rules.map(&:keys) # => [[:email]]
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
This is useful for building documentation generators, admin panels, or debugging which validations an action enforces.
|
|
246
|
+
|
|
247
|
+
### When to use `.contract` directly
|
|
248
|
+
|
|
249
|
+
- **Form validation endpoints** -- validate input and return errors without creating or modifying resources.
|
|
250
|
+
- **Testing validation rules in isolation** -- assert that specific inputs produce specific errors without needing to stub dependencies that `#call` would use.
|
|
251
|
+
- **REPL exploration** -- inspect what an action expects by calling its contract interactively.
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
class Users::CreateActionTest < Minitest::Test
|
|
255
|
+
def test_email_is_required
|
|
256
|
+
result = Users::CreateAction.contract.call(name: "Jane")
|
|
257
|
+
|
|
258
|
+
assert result.failure?
|
|
259
|
+
assert_includes result.errors.to_h[:email], "is missing"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Conventions
|
|
267
|
+
|
|
268
|
+
- **Assert fully** -- for validation and rule failures, assert both the HTTP status and the error message. Testing only the status does not prove the correct validation failed.
|
|
269
|
+
- **Named locals, not subject** -- use a descriptive local variable (`result`, `action`) in each test instead of a shared `subject` helper method.
|
|
270
|
+
- **Use return values for assertions** -- assert on what `Ok(resource: ...)` returns rather than capturing outer variables with closures.
|
data/docs/validation.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
ActionFigure provides a two-layer validation pipeline powered by [dry-validation](https://dry-rb.org/gems/dry-validation/). The pipeline runs **before** your `#call` method, so if validation fails, your operation logic is never executed. The caller receives an `UnprocessableContent` result containing structured errors.
|
|
6
|
+
|
|
7
|
+
The two layers are:
|
|
8
|
+
|
|
9
|
+
1. **`params_schema`** -- structural validation and type coercion (powered by dry-schema)
|
|
10
|
+
2. **`rules`** -- validation rules that run only after the schema passes
|
|
11
|
+
|
|
12
|
+
If `params:` is not passed to the action at all, validation is skipped entirely and `#call` is invoked directly. If `params:` is passed but no `params_schema` is defined, an `ArgumentError` is raised immediately.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## params_schema
|
|
17
|
+
|
|
18
|
+
`params_schema` accepts a block written in the [dry-schema](https://dry-rb.org/gems/dry-schema/) DSL. It defines the shape of your input: which keys are allowed, which are required, and what types they must be.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
class Users::CreateAction
|
|
22
|
+
include ActionFigure[:jsend]
|
|
23
|
+
|
|
24
|
+
params_schema do
|
|
25
|
+
required(:email).filled(:string)
|
|
26
|
+
required(:name).filled(:string)
|
|
27
|
+
optional(:age).filled(:integer)
|
|
28
|
+
optional(:newsletter).filled(:bool)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(params:, **)
|
|
32
|
+
user = User.create(params)
|
|
33
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
34
|
+
|
|
35
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
36
|
+
Ok(resource:)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### required vs optional
|
|
42
|
+
|
|
43
|
+
- `required(:field)` -- the key **must** be present in the input. If missing, validation fails before rules ever run.
|
|
44
|
+
- `optional(:field)` -- the key may be omitted. If present, it still must satisfy the declared type and predicates.
|
|
45
|
+
|
|
46
|
+
### Type coercion
|
|
47
|
+
|
|
48
|
+
Because ActionFigure uses a **params** schema (not a plain schema), dry-schema automatically coerces string values into their declared types. This is essential for web requests where everything arrives as a string.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
params_schema do
|
|
52
|
+
required(:quantity).filled(:integer)
|
|
53
|
+
required(:price).filled(:float)
|
|
54
|
+
required(:active).filled(:bool)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Input: { quantity: "25", price: "9.99", active: "true" }
|
|
58
|
+
# Output: { quantity: 25, price: 9.99, active: true }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Common coercible types: `:string`, `:integer`, `:float`, `:decimal`, `:bool`, `:date`, `:time`, `:date_time`.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## rules
|
|
66
|
+
|
|
67
|
+
`rules` run **after** the schema passes, giving you access to fully validated and coerced values. Use `rules` for constraints that span multiple fields or require context that the schema DSL cannot express (e.g., database lookups). ActionFigure includes several cross-parameter helpers (documented below) to simplify common multi-field rules.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class Users::CreateAction
|
|
71
|
+
include ActionFigure[:jsend]
|
|
72
|
+
|
|
73
|
+
params_schema do
|
|
74
|
+
required(:email).filled(:string)
|
|
75
|
+
required(:name).filled(:string)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
rules do
|
|
79
|
+
rule(:email) do
|
|
80
|
+
if values[:email] && User.exists?(email: values[:email])
|
|
81
|
+
key.failure("is already taken")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call(params:, **)
|
|
87
|
+
user = User.create(params)
|
|
88
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
89
|
+
|
|
90
|
+
Ok(resource: user)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Key details
|
|
96
|
+
|
|
97
|
+
- `rules` **must** be declared after `params_schema`. Declaring it without a schema raises:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
ArgumentError: rules requires params_schema to be defined
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Inside a rule block, access validated values with `values[:field]`.
|
|
104
|
+
- Add an error to a specific field with `key(:field).failure("message")`, or `key.failure("message")` when the rule is scoped to a single field via `rule(:field)`.
|
|
105
|
+
- Multiple rules can target the same field. All rules run even if earlier ones fail -- errors accumulate.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Cross-Param Rule Helpers
|
|
110
|
+
|
|
111
|
+
ActionFigure ships four helpers for common multi-field constraints. They are available inside `rules` blocks and save you from writing the same boilerplate patterns repeatedly.
|
|
112
|
+
|
|
113
|
+
All helpers share a consistent definition of **"present"**: a field is considered present when its key exists in the validated values **and** its value is not `nil`. Notably, `false` counts as present -- only `nil` (or a missing key) counts as absent.
|
|
114
|
+
|
|
115
|
+
### exclusive_rule
|
|
116
|
+
|
|
117
|
+
At most one of the listed fields may be present. If multiple are present, each **present** field receives the error message.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
class Orders::SearchAction
|
|
121
|
+
include ActionFigure[:jsend]
|
|
122
|
+
params_schema do
|
|
123
|
+
optional(:order_id).filled(:string)
|
|
124
|
+
optional(:tracking_number).filled(:string)
|
|
125
|
+
optional(:customer_email).filled(:string)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
rules do
|
|
129
|
+
exclusive_rule(:order_id, :tracking_number, :customer_email,
|
|
130
|
+
"provide only one search criterion")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def call(params:, **)
|
|
134
|
+
# exactly zero or one search key is guaranteed here
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Given `{ order_id: "123", tracking_number: "TRK-456" }`, both `order_id` and `tracking_number` receive the error. Passing zero fields is fine -- use `any_rule` if you need at least one.
|
|
140
|
+
|
|
141
|
+
### any_rule
|
|
142
|
+
|
|
143
|
+
At least one of the listed fields must be present. If none are present, **every** listed field receives the error message.
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
class Orders::SearchAction
|
|
147
|
+
include ActionFigure[:jsend]
|
|
148
|
+
params_schema do
|
|
149
|
+
optional(:order_id).filled(:string)
|
|
150
|
+
optional(:tracking_number).filled(:string)
|
|
151
|
+
optional(:customer_email).filled(:string)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
rules do
|
|
155
|
+
any_rule(:order_id, :tracking_number, :customer_email,
|
|
156
|
+
"at least one search criterion is required")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def call(params:, **)
|
|
160
|
+
# guaranteed at least one search field is present
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Given `{}`, all three fields receive the error. Given `{ order_id: "123", tracking_number: "TRK-456" }`, validation passes -- multiple present fields are fine.
|
|
166
|
+
|
|
167
|
+
### one_rule
|
|
168
|
+
|
|
169
|
+
Exactly one of the listed fields must be present. If zero or more than one are present, **every** listed field receives the error message.
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class Payments::CreateAction
|
|
173
|
+
include ActionFigure[:jsend]
|
|
174
|
+
params_schema do
|
|
175
|
+
required(:amount).filled(:integer)
|
|
176
|
+
optional(:credit_card_token).filled(:string)
|
|
177
|
+
optional(:bank_account_id).filled(:string)
|
|
178
|
+
optional(:wallet_id).filled(:string)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
rules do
|
|
182
|
+
one_rule(:credit_card_token, :bank_account_id, :wallet_id,
|
|
183
|
+
"exactly one payment method is required")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def call(params:, **)
|
|
187
|
+
# exactly one payment method is guaranteed
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Given `{ amount: 5000, credit_card_token: "tok_123", wallet_id: "wal_456" }`, all three payment method fields receive the error. Given `{ amount: 5000 }` with no payment method, all three also receive the error.
|
|
193
|
+
|
|
194
|
+
### all_rule
|
|
195
|
+
|
|
196
|
+
All listed fields must be present together, or all must be absent. A partial set causes **every** listed field to receive the error message.
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Users::CreateAction
|
|
200
|
+
include ActionFigure[:jsend]
|
|
201
|
+
|
|
202
|
+
params_schema do
|
|
203
|
+
required(:name).filled(:string)
|
|
204
|
+
optional(:street).filled(:string)
|
|
205
|
+
optional(:city).filled(:string)
|
|
206
|
+
optional(:zip).filled(:string)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
rules do
|
|
210
|
+
all_rule(:street, :city, :zip,
|
|
211
|
+
"address fields must be provided together or not at all")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def call(params:, **)
|
|
215
|
+
# address is either complete or entirely absent
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Given `{ name: "Jane", street: "123 Main St" }`, all three address fields receive the error because only one of three is present. Given `{ name: "Jane" }` (none present) or `{ name: "Jane", street: "123 Main St", city: "Portland", zip: "97201" }` (all present), validation passes.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Extra Parameter Handling
|
|
225
|
+
|
|
226
|
+
By default, dry-validation silently strips any keys that are not declared in the schema. Your `#call` method only ever sees the declared fields:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
class Users::CreateAction
|
|
230
|
+
include ActionFigure[:jsend]
|
|
231
|
+
|
|
232
|
+
params_schema do
|
|
233
|
+
required(:email).filled(:string)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def call(params:, **)
|
|
237
|
+
params #=> { email: "jane@example.com" }
|
|
238
|
+
# :admin was silently removed
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Called with: { email: "jane@example.com", admin: true }
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### whiny_extra_params
|
|
246
|
+
|
|
247
|
+
If you prefer to reject unexpected parameters outright, enable the `whiny_extra_params` configuration option:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
ActionFigure.configure do |config|
|
|
251
|
+
config.whiny_extra_params = true
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
With this enabled, passing undeclared parameters returns an `UnprocessableContent` result with a 422 status. The error shape is consistent with all other validation errors:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
# Called with: { email: "jane@example.com", admin: true, role: "superuser" }
|
|
259
|
+
|
|
260
|
+
# Result errors:
|
|
261
|
+
{
|
|
262
|
+
admin: ["is not allowed"],
|
|
263
|
+
role: ["is not allowed"]
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Each extra key receives its own `"is not allowed"` error message. This check runs **after** schema validation succeeds, so you will see schema errors or extra-param errors, never both at the same time.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## ActionController::Parameters
|
|
272
|
+
|
|
273
|
+
In Rails controllers, form data arrives as `ActionController::Parameters` rather than a plain `Hash`. ActionFigure handles this automatically: when it detects an object that responds to `to_unsafe_h`, it calls that method to convert it to a regular hash before validation.
|
|
274
|
+
|
|
275
|
+
This means you can pass `params` from a controller directly without calling `permit`, `require`, or `to_h` yourself -- the action's `params_schema` handles all of that:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
class UsersController < ApplicationController
|
|
279
|
+
def create
|
|
280
|
+
render Users::CreateAction.call(
|
|
281
|
+
params:,
|
|
282
|
+
current_user: current_user
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Plain hashes work identically -- ActionFigure only calls `to_unsafe_h` when the method is available. This makes actions easy to test without constructing `ActionController::Parameters` objects:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
result = Users::CreateAction.call(
|
|
292
|
+
params: { user: { email: "jane@example.com", name: "Jane" } }
|
|
293
|
+
)
|
|
294
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionFigure
|
|
4
|
+
# Provides global configuration for ActionFigure via ActionFigure.configure.
|
|
5
|
+
module Configuration
|
|
6
|
+
# Holds ActionFigure configuration values.
|
|
7
|
+
class Settings
|
|
8
|
+
attr_accessor :format, :whiny_extra_params, :api_version, :activesupport_notifications
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@format = :default
|
|
12
|
+
@whiny_extra_params = false
|
|
13
|
+
@activesupport_notifications = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def register(**formatters)
|
|
21
|
+
ActionFigure.register_formatter(**formatters)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure(&)
|
|
26
|
+
configuration.configure(&)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configuration
|
|
30
|
+
@configuration ||= Settings.new
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|