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.
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.
@@ -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