action_figure 0.1.0 → 0.6.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.
@@ -0,0 +1,35 @@
1
+ # HTTP 4xx Status Codes
2
+
3
+ Rows in **bold** are status codes with built-in formatter methods — action classes can return these directly via helpers like `NotFound(errors:)` or `Conflict(errors:)`. All other 4xx codes are handled outside action classes by the perimeter (middleware, router, Rack, or infrastructure).
4
+
5
+ | Status | Name | Category | Responsibility | Description / Logic Example |
6
+ |--------|----------------------------------|-----------|-----------------|----------------------------------------------------------------------|
7
+ | 400 | Bad Request | Perimeter | Controller/Rack | Malformed syntax or missing top-level structure. |
8
+ | 401 | Unauthorized | Perimeter | Middleware/Auth | Authentication failed or missing credentials. |
9
+ | **402** | **Payment Required** | **Domain** | **Action Class** | **Business state: "Subscription overdue" or "Quota exceeded."** |
10
+ | **403** | **Forbidden** | **Domain** | **Action Class** | **Authenticated, but lacks permissions for this specific task.** |
11
+ | **404** | **Not Found** | **Domain** | **Action Class** | **The requested resource ID does not exist in the database.** |
12
+ | 405 | Method Not Allowed | Perimeter | Rails Router | Sending a POST to a GET route. |
13
+ | 406 | Not Acceptable | Perimeter | Controller | Client requested a format (e.g., XML) the server won't provide. |
14
+ | 407 | Proxy Auth Required | Perimeter | Infrastructure | Similar to 401, but for a proxy server. |
15
+ | 408 | Request Timeout | Perimeter | Server/Nginx | The client took too long to send the request. |
16
+ | **409** | **Conflict** | **Domain** | **Action Class** | **Resource already exists, or the state is in conflict.** |
17
+ | 410 | Gone | Domain | Action Class | The resource is permanently deleted (not just 404). |
18
+ | 411 | Length Required | Perimeter | Server/Rack | The request didn't specify a Content-Length. |
19
+ | 412 | Precondition Failed | Perimeter | Controller/Rack | If-Match headers don't match (usually for caching). |
20
+ | 413 | Payload Too Large | Perimeter | Server/Nginx | The request body is bigger than the server allows. |
21
+ | 414 | URI Too Long | Perimeter | Server/Nginx | The URL is too long for the server to process. |
22
+ | 415 | Unsupported Media Type | Perimeter | Controller | Sending text/plain instead of application/json. |
23
+ | 416 | Range Not Satisfiable | Perimeter | Server/Rack | Invalid Range header (usually for file downloads). |
24
+ | 417 | Expectation Failed | Perimeter | Server | The server can't meet the Expect header requirements. |
25
+ | 418 | I'm a teapot | Domain | Action Class | An IETF April Fools joke (rarely used in production). |
26
+ | 421 | Misdirected Request | Perimeter | Infrastructure | The server can't produce a response for this connection. |
27
+ | **422** | **Unprocessable Content** | **Domain** | **Action Class** | **Semantic errors (validation, business rules).** |
28
+ | 423 | Locked | Domain | Action Class | The resource is being accessed by another process. |
29
+ | 424 | Failed Dependency | Domain | Action Class | The request failed due to a failure of a previous request. |
30
+ | 425 | Too Early | Perimeter | Server/Rack | The server is unwilling to process a request that might be replayed. |
31
+ | 426 | Upgrade Required | Perimeter | Server/Rack | The client must switch to a different protocol (e.g., TLS). |
32
+ | 428 | Precondition Required | Perimeter | Controller/Rack | The server requires the request to be conditional. |
33
+ | 429 | Too Many Requests | Perimeter | Rack::Attack | Infrastructure-level rate limiting (IP-based, etc.). |
34
+ | 431 | Request Header Fields Too Large | Perimeter | Server/Rack | HTTP headers are too large. |
35
+ | 451 | Unavailable For Legal Reasons | Domain | Action Class | Resource censored/blocked for legal/regional reasons. |
data/docs/testing.md ADDED
@@ -0,0 +1,272 @@
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
+ | `assert_Conflict(result)` | `:conflict` |
37
+ | `assert_PaymentRequired(result)` | `:payment_required` |
38
+
39
+ All assertions accept an optional second argument for a custom failure message:
40
+
41
+ ```ruby
42
+ assert_Ok(result, "expected the user to be created successfully")
43
+ ```
44
+
45
+ When a status assertion fails, the default message shows the expected and actual status:
46
+
47
+ ```
48
+ Expected result status to be :ok, but got :unprocessable_content
49
+ ```
50
+
51
+ ---
52
+
53
+ ## RSpec
54
+
55
+ ### Setup
56
+
57
+ Require the helper in your spec support file. No `include` is needed -- the matchers are registered globally:
58
+
59
+ ```ruby
60
+ # spec/spec_helper.rb
61
+ require "action_figure/testing/rspec"
62
+ ```
63
+
64
+ ### Matchers
65
+
66
+ | Matcher | Expected status |
67
+ |---------------------------|--------------------------|
68
+ | `be_Ok` | `:ok` |
69
+ | `be_Created` | `:created` |
70
+ | `be_Accepted` | `:accepted` |
71
+ | `be_NoContent` | `:no_content` |
72
+ | `be_UnprocessableContent` | `:unprocessable_content` |
73
+ | `be_NotFound` | `:not_found` |
74
+ | `be_Forbidden` | `:forbidden` |
75
+ | `be_Conflict` | `:conflict` |
76
+ | `be_PaymentRequired` | `:payment_required` |
77
+
78
+ Matchers support negation:
79
+
80
+ ```ruby
81
+ expect(result).to be_Ok
82
+ expect(result).not_to be_Forbidden
83
+ ```
84
+
85
+ Failure messages mirror the Minitest style:
86
+
87
+ ```
88
+ expected result status to be :ok, but got :unprocessable_content
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Testing Patterns
94
+
95
+ The examples below use Minitest, but the same patterns apply to RSpec with the corresponding matchers.
96
+
97
+ 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.
98
+
99
+ ### Testing a Successful Action
100
+
101
+ Call your class and assert both the status and the returned data:
102
+
103
+ ```ruby
104
+ class Users::CreateActionTest < Minitest::Test
105
+ include ActionFigure::Testing::Minitest
106
+
107
+ def test_creates_a_user
108
+ result = Users::CreateAction.create(params: { email: "jane@example.com", name: "Jane" })
109
+
110
+ assert_Ok(result)
111
+ assert_equal "jane@example.com", result[:json][:data][:email]
112
+ assert_equal "Jane", result[:json][:data][:name]
113
+ end
114
+ end
115
+ ```
116
+
117
+ ### Testing Validation Failure
118
+
119
+ 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.
120
+
121
+ ```ruby
122
+ class Users::CreateActionTest < Minitest::Test
123
+ include ActionFigure::Testing::Minitest
124
+
125
+ def test_rejects_missing_email
126
+ result = Users::CreateAction.create(params: { name: "Jane" })
127
+
128
+ assert_UnprocessableContent(result)
129
+ assert_includes result[:json][:data][:email], "is missing"
130
+ end
131
+ end
132
+ ```
133
+
134
+ ### Testing with Context Injection
135
+
136
+ Actions often receive context such as `current_user:` as keyword arguments alongside `params:`. Pass them directly in the test:
137
+
138
+ ```ruby
139
+ class Posts::CreateActionTest < Minitest::Test
140
+ include ActionFigure::Testing::Minitest
141
+
142
+ def test_creates_a_post_for_the_current_user
143
+ user = users(:jane)
144
+ result = Posts::CreateAction.create(params: { title: "Hello", body: "World" }, current_user: user)
145
+
146
+ assert_Created(result)
147
+ assert_equal user.id, result[:json][:data][:author_id]
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### Testing an Action with a Named Method
153
+
154
+ Call the action using its discovered method name:
155
+
156
+ ```ruby
157
+ class Products::SearchActionTest < Minitest::Test
158
+ include ActionFigure::Testing::Minitest
159
+
160
+ # class SearchAction
161
+ # include ActionFigure[:jsend]
162
+ #
163
+ # params_schema do
164
+ # required(:query).filled(:string)
165
+ # end
166
+ #
167
+ # def search(params:, **)
168
+ # products = Product.where("name ILIKE ?", "%#{params[:query]}%")
169
+ # Ok(resource: products)
170
+ # end
171
+ # end
172
+ def test_finds_matching_products
173
+ result = SearchAction.search(params: { query: "keyboard" })
174
+
175
+ assert_Ok(result)
176
+ assert result[:json][:data].any?, "expected at least one matching product"
177
+ end
178
+ end
179
+ ```
180
+
181
+ ### Testing NoContent
182
+
183
+ Actions that perform side effects without returning data use `NoContent()`:
184
+
185
+ ```ruby
186
+ class Sessions::DestroyActionTest < Minitest::Test
187
+ include ActionFigure::Testing::Minitest
188
+
189
+ # class Sessions::DestroyAction
190
+ # include ActionFigure[:jsend]
191
+ #
192
+ # def destroy(session:)
193
+ # session.destroy!
194
+ # NoContent()
195
+ # end
196
+ # end
197
+ def test_destroys_the_session
198
+ session = sessions(:active)
199
+ result = Sessions::DestroyAction.destroy(session: session)
200
+
201
+ assert_NoContent(result)
202
+ end
203
+ end
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Standalone Validation with `.contract`
209
+
210
+ 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.
211
+
212
+ ```ruby
213
+ contract = Users::CreateAction.contract
214
+ result = contract.call(email: "jane@example.com", name: "Jane")
215
+
216
+ result.success? # => true
217
+ result.to_h # => { email: "jane@example.com", name: "Jane" }
218
+ ```
219
+
220
+ When validation fails, inspect the errors:
221
+
222
+ ```ruby
223
+ result = Users::CreateAction.contract.call(email: "", name: "Jane")
224
+
225
+ result.failure? # => true
226
+ result.errors.to_h # => { email: ["must be filled"] }
227
+ ```
228
+
229
+ This runs both the schema and any `rules` defined on the action -- the same validation pipeline that the class-level trigger uses, without the side effects.
230
+
231
+ Actions that do not define a `params_schema` return `nil` from `.contract`.
232
+
233
+ ### Inspecting schema and rules
234
+
235
+ The contract exposes the schema and rules for introspection:
236
+
237
+ ```ruby
238
+ contract = Users::CreateAction.contract
239
+
240
+ contract.schema # => the Dry::Schema::Params instance
241
+ contract.schema.key_map.map(&:name) # => ["email", "name"]
242
+
243
+ contract.rules # => array of Dry::Validation::Rule objects
244
+ contract.rules.map(&:keys) # => [[:email]]
245
+ ```
246
+
247
+ This is useful for building documentation generators, admin panels, or debugging which validations an action enforces.
248
+
249
+ ### When to use `.contract` directly
250
+
251
+ - **Form validation endpoints** -- validate input and return errors without creating or modifying resources.
252
+ - **Testing validation rules in isolation** -- assert that specific inputs produce specific errors without needing to stub dependencies that `#call` would use.
253
+ - **REPL exploration** -- inspect what an action expects by calling its contract interactively.
254
+
255
+ ```ruby
256
+ class Users::CreateActionTest < Minitest::Test
257
+ def test_email_is_required
258
+ result = Users::CreateAction.contract.call(name: "Jane")
259
+
260
+ assert result.failure?
261
+ assert_includes result.errors.to_h[:email], "is missing"
262
+ end
263
+ end
264
+ ```
265
+
266
+ ---
267
+
268
+ ## Conventions
269
+
270
+ - **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.
271
+ - **Named locals, not subject** -- use a descriptive local variable (`result`, `action`) in each test instead of a shared `subject` helper method.
272
+ - **Use return values for assertions** -- assert on what `Ok(resource: ...)` returns rather than capturing outer variables with closures.
@@ -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 no `params_schema` is defined, `params:` passes through to your `#call` method as-is — no validation, no coercion, no stripping of extra keys. This lets you rely on upstream validation (e.g., Rack middleware like `committee`) while still using ActionFigure for orchestration and response formatting.
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