action_figure 0.5.0 → 0.6.2
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/CHANGELOG.md +100 -0
- data/README.md +140 -159
- data/docs/actions.md +68 -60
- data/docs/activesupport-notifications.md +12 -10
- data/docs/configuration.md +16 -2
- data/docs/custom-formatters.md +18 -6
- data/docs/response-formatters.md +159 -3
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +28 -10
- data/docs/validation.md +5 -1
- data/lib/action_figure/core.rb +81 -26
- data/lib/action_figure/format_registry.rb +4 -2
- data/lib/action_figure/formatter.rb +3 -1
- data/lib/action_figure/formatters/default.rb +15 -5
- data/lib/action_figure/formatters/jsend.rb +8 -0
- data/lib/action_figure/formatters/json_api/resource.rb +4 -6
- data/lib/action_figure/formatters/json_api.rb +21 -8
- data/lib/action_figure/formatters/wrapped.rb +8 -0
- data/lib/action_figure/testing/minitest.rb +8 -0
- data/lib/action_figure/testing/rspec.rb +33 -3
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +12 -1
- data/sig/action_figure.rbs +176 -1
- metadata +19 -2
data/docs/response-formatters.md
CHANGED
|
@@ -47,7 +47,7 @@ end
|
|
|
47
47
|
|
|
48
48
|
## Response Helpers
|
|
49
49
|
|
|
50
|
-
Every formatter implements the same
|
|
50
|
+
Every formatter implements the same nine response helpers. Eight return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`.
|
|
51
51
|
|
|
52
52
|
| Helper | HTTP Status | When to Use |
|
|
53
53
|
|---------------------------------|--------------------------|--------------------------------------------------|
|
|
@@ -55,9 +55,11 @@ Every formatter implements the same seven response helpers. Six return a hash wi
|
|
|
55
55
|
| `Created(resource:, meta: nil)` | `201 Created` | Successful resource creation |
|
|
56
56
|
| `Accepted(resource: nil, meta: nil)` | `202 Accepted` | Request accepted for background processing |
|
|
57
57
|
| `NoContent()` | `204 No Content` | Successful delete or action with no response body|
|
|
58
|
-
| `
|
|
59
|
-
| `NotFound(errors:)` | `404 Not Found` | Resource not found |
|
|
58
|
+
| `PaymentRequired(errors:)` | `402 Payment Required` | Business billing or quota constraint |
|
|
60
59
|
| `Forbidden(errors:)` | `403 Forbidden` | Authorization failure |
|
|
60
|
+
| `NotFound(errors:)` | `404 Not Found` | Resource not found |
|
|
61
|
+
| `Conflict(errors:)` | `409 Conflict` | Resource state conflict or duplicate |
|
|
62
|
+
| `UnprocessableContent(errors:)` | `422 Unprocessable Content` | Validation failures |
|
|
61
63
|
|
|
62
64
|
`NoContent` is shared across all formatters and is defined in the base `Formatter` module. It returns `{ status: :no_content }` with no JSON body.
|
|
63
65
|
|
|
@@ -227,6 +229,41 @@ end
|
|
|
227
229
|
}
|
|
228
230
|
```
|
|
229
231
|
|
|
232
|
+
**`Conflict`:**
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
def call(params:)
|
|
236
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
237
|
+
user = User.create(params)
|
|
238
|
+
Created(resource: user)
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"errors": {
|
|
245
|
+
"email": ["already registered"]
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**`PaymentRequired`:**
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
def call(params:, current_user:)
|
|
254
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
255
|
+
Ok(resource: Dashboard.for(current_user))
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"errors": {
|
|
262
|
+
"base": ["subscription expired"]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
230
267
|
## JSend Format
|
|
231
268
|
|
|
232
269
|
The JSend formatter wraps responses in the [JSend specification](https://github.com/omniti-labs/jsend) envelope.
|
|
@@ -382,6 +419,43 @@ end
|
|
|
382
419
|
}
|
|
383
420
|
```
|
|
384
421
|
|
|
422
|
+
**`Conflict`:**
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
def call(params:)
|
|
426
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
427
|
+
user = User.create(params)
|
|
428
|
+
Created(resource: user)
|
|
429
|
+
end
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
{
|
|
434
|
+
"status": "fail",
|
|
435
|
+
"data": {
|
|
436
|
+
"email": ["already registered"]
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**`PaymentRequired`:**
|
|
442
|
+
|
|
443
|
+
```ruby
|
|
444
|
+
def call(params:, current_user:)
|
|
445
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
446
|
+
Ok(resource: Dashboard.for(current_user))
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
```json
|
|
451
|
+
{
|
|
452
|
+
"status": "fail",
|
|
453
|
+
"data": {
|
|
454
|
+
"base": ["subscription expired"]
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
385
459
|
## Wrapped Format
|
|
386
460
|
|
|
387
461
|
The Wrapped formatter places every response in a uniform `{ data:, errors:, status: }` envelope. Success responses use `"status": "success"` and failure responses use `"status": "error"`.
|
|
@@ -543,6 +617,45 @@ end
|
|
|
543
617
|
}
|
|
544
618
|
```
|
|
545
619
|
|
|
620
|
+
**`Conflict`:**
|
|
621
|
+
|
|
622
|
+
```ruby
|
|
623
|
+
def call(params:)
|
|
624
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
625
|
+
user = User.create(params)
|
|
626
|
+
Created(resource: user)
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
```json
|
|
631
|
+
{
|
|
632
|
+
"data": null,
|
|
633
|
+
"errors": {
|
|
634
|
+
"email": ["already registered"]
|
|
635
|
+
},
|
|
636
|
+
"status": "error"
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
**`PaymentRequired`:**
|
|
641
|
+
|
|
642
|
+
```ruby
|
|
643
|
+
def call(params:, current_user:)
|
|
644
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
645
|
+
Ok(resource: Dashboard.for(current_user))
|
|
646
|
+
end
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
```json
|
|
650
|
+
{
|
|
651
|
+
"data": null,
|
|
652
|
+
"errors": {
|
|
653
|
+
"base": ["subscription expired"]
|
|
654
|
+
},
|
|
655
|
+
"status": "error"
|
|
656
|
+
}
|
|
657
|
+
```
|
|
658
|
+
|
|
546
659
|
## JSON:API Format
|
|
547
660
|
|
|
548
661
|
The JSON:API formatter structures responses according to the [JSON:API specification](https://jsonapi.org/).
|
|
@@ -739,6 +852,49 @@ end
|
|
|
739
852
|
}
|
|
740
853
|
```
|
|
741
854
|
|
|
855
|
+
**`Conflict`:**
|
|
856
|
+
|
|
857
|
+
```ruby
|
|
858
|
+
def call(params:)
|
|
859
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
860
|
+
user = User.create(params)
|
|
861
|
+
Created(resource: user)
|
|
862
|
+
end
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
```json
|
|
866
|
+
{
|
|
867
|
+
"errors": [
|
|
868
|
+
{
|
|
869
|
+
"status": "409",
|
|
870
|
+
"detail": "already registered",
|
|
871
|
+
"source": { "pointer": "/data/attributes/email" }
|
|
872
|
+
}
|
|
873
|
+
]
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
**`PaymentRequired`:**
|
|
878
|
+
|
|
879
|
+
```ruby
|
|
880
|
+
def call(params:, current_user:)
|
|
881
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
882
|
+
Ok(resource: Dashboard.for(current_user))
|
|
883
|
+
end
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
```json
|
|
887
|
+
{
|
|
888
|
+
"errors": [
|
|
889
|
+
{
|
|
890
|
+
"status": "402",
|
|
891
|
+
"detail": "subscription expired",
|
|
892
|
+
"source": { "pointer": "/data" }
|
|
893
|
+
}
|
|
894
|
+
]
|
|
895
|
+
}
|
|
896
|
+
```
|
|
897
|
+
|
|
742
898
|
## ActiveRecord Serialization (JSON:API)
|
|
743
899
|
|
|
744
900
|
The JSON:API formatter includes automatic serialization for ActiveRecord objects via the `Resource` class.
|
|
@@ -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
CHANGED
|
@@ -33,6 +33,10 @@ end
|
|
|
33
33
|
| `assert_UnprocessableContent(result)` | `:unprocessable_content` |
|
|
34
34
|
| `assert_NotFound(result)` | `:not_found` |
|
|
35
35
|
| `assert_Forbidden(result)` | `:forbidden` |
|
|
36
|
+
| `assert_Conflict(result)` | `:conflict` |
|
|
37
|
+
| `assert_PaymentRequired(result)` | `:payment_required` |
|
|
38
|
+
|
|
39
|
+
These helpers compare **only `result[:status]`** against the Rack-style symbol Rails uses in **`render`** — they **do not** assert on **`[:json]`** keys, payloads, or error message text. Combine them with assertions on **`result[:json]`** (or matchers on the body your formatter produces) whenever shape matters.
|
|
36
40
|
|
|
37
41
|
All assertions accept an optional second argument for a custom failure message:
|
|
38
42
|
|
|
@@ -59,6 +63,8 @@ Require the helper in your spec support file. No `include` is needed -- the matc
|
|
|
59
63
|
require "action_figure/testing/rspec"
|
|
60
64
|
```
|
|
61
65
|
|
|
66
|
+
**Load order:** require this library **after** RSpec Core and expectations load (usual practice: append it toward the **bottom** of `spec/spec_helper.rb`, after any `require "rails_helper"` / `RSpec.configure` boilerplate from your app). ActionFigure pulls in **`rspec/matchers`**; minimalist scripts without the full **`rspec` CLI shim** must **`require "rspec/expectations"`** (and typically **`require "rspec/core"`**) *before* this file.
|
|
67
|
+
|
|
62
68
|
### Matchers
|
|
63
69
|
|
|
64
70
|
| Matcher | Expected status |
|
|
@@ -70,6 +76,20 @@ require "action_figure/testing/rspec"
|
|
|
70
76
|
| `be_UnprocessableContent` | `:unprocessable_content` |
|
|
71
77
|
| `be_NotFound` | `:not_found` |
|
|
72
78
|
| `be_Forbidden` | `:forbidden` |
|
|
79
|
+
| `be_Conflict` | `:conflict` |
|
|
80
|
+
| `be_PaymentRequired` | `:payment_required` |
|
|
81
|
+
| `have_action_json` | `result[:json]` matches `a_hash_including(fragment)` |
|
|
82
|
+
|
|
83
|
+
Like the Minitest helpers, each **`be_*`** matcher compares **only `result[:status]`** — **`[:json]`** is ignored unless you assert on it separately. Use **`have_action_json`** when you want a focused assertion against the **`json`** body (compose with **`a_hash_including`** for nested subsets):
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
expect(result).to be_Ok
|
|
87
|
+
expect(result).to have_action_json(status: "success")
|
|
88
|
+
expect(result).to have_action_json(
|
|
89
|
+
status: "success",
|
|
90
|
+
data: a_hash_including(name: "Jane")
|
|
91
|
+
)
|
|
92
|
+
```
|
|
73
93
|
|
|
74
94
|
Matchers support negation:
|
|
75
95
|
|
|
@@ -101,7 +121,7 @@ class Users::CreateActionTest < Minitest::Test
|
|
|
101
121
|
include ActionFigure::Testing::Minitest
|
|
102
122
|
|
|
103
123
|
def test_creates_a_user
|
|
104
|
-
result = Users::CreateAction.
|
|
124
|
+
result = Users::CreateAction.create(params: { email: "jane@example.com", name: "Jane" })
|
|
105
125
|
|
|
106
126
|
assert_Ok(result)
|
|
107
127
|
assert_equal "jane@example.com", result[:json][:data][:email]
|
|
@@ -119,7 +139,7 @@ class Users::CreateActionTest < Minitest::Test
|
|
|
119
139
|
include ActionFigure::Testing::Minitest
|
|
120
140
|
|
|
121
141
|
def test_rejects_missing_email
|
|
122
|
-
result = Users::CreateAction.
|
|
142
|
+
result = Users::CreateAction.create(params: { name: "Jane" })
|
|
123
143
|
|
|
124
144
|
assert_UnprocessableContent(result)
|
|
125
145
|
assert_includes result[:json][:data][:email], "is missing"
|
|
@@ -137,7 +157,7 @@ class Posts::CreateActionTest < Minitest::Test
|
|
|
137
157
|
|
|
138
158
|
def test_creates_a_post_for_the_current_user
|
|
139
159
|
user = users(:jane)
|
|
140
|
-
result = Posts::CreateAction.
|
|
160
|
+
result = Posts::CreateAction.create(params: { title: "Hello", body: "World" }, current_user: user)
|
|
141
161
|
|
|
142
162
|
assert_Created(result)
|
|
143
163
|
assert_equal user.id, result[:json][:data][:author_id]
|
|
@@ -145,9 +165,9 @@ class Posts::CreateActionTest < Minitest::Test
|
|
|
145
165
|
end
|
|
146
166
|
```
|
|
147
167
|
|
|
148
|
-
### Testing a
|
|
168
|
+
### Testing an Action with a Named Method
|
|
149
169
|
|
|
150
|
-
|
|
170
|
+
Call the action using its discovered method name:
|
|
151
171
|
|
|
152
172
|
```ruby
|
|
153
173
|
class Products::SearchActionTest < Minitest::Test
|
|
@@ -156,8 +176,6 @@ class Products::SearchActionTest < Minitest::Test
|
|
|
156
176
|
# class SearchAction
|
|
157
177
|
# include ActionFigure[:jsend]
|
|
158
178
|
#
|
|
159
|
-
# entry_point :search
|
|
160
|
-
#
|
|
161
179
|
# params_schema do
|
|
162
180
|
# required(:query).filled(:string)
|
|
163
181
|
# end
|
|
@@ -187,14 +205,14 @@ class Sessions::DestroyActionTest < Minitest::Test
|
|
|
187
205
|
# class Sessions::DestroyAction
|
|
188
206
|
# include ActionFigure[:jsend]
|
|
189
207
|
#
|
|
190
|
-
# def
|
|
208
|
+
# def destroy(session:)
|
|
191
209
|
# session.destroy!
|
|
192
210
|
# NoContent()
|
|
193
211
|
# end
|
|
194
212
|
# end
|
|
195
213
|
def test_destroys_the_session
|
|
196
214
|
session = sessions(:active)
|
|
197
|
-
result = Sessions::DestroyAction.
|
|
215
|
+
result = Sessions::DestroyAction.destroy(session: session)
|
|
198
216
|
|
|
199
217
|
assert_NoContent(result)
|
|
200
218
|
end
|
|
@@ -224,7 +242,7 @@ result.failure? # => true
|
|
|
224
242
|
result.errors.to_h # => { email: ["must be filled"] }
|
|
225
243
|
```
|
|
226
244
|
|
|
227
|
-
This runs both the schema and any `rules` defined on the action -- the same validation pipeline that
|
|
245
|
+
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.
|
|
228
246
|
|
|
229
247
|
Actions that do not define a `params_schema` return `nil` from `.contract`.
|
|
230
248
|
|
data/docs/validation.md
CHANGED
|
@@ -9,7 +9,7 @@ The two layers are:
|
|
|
9
9
|
1. **`params_schema`** -- structural validation and type coercion (powered by dry-schema)
|
|
10
10
|
2. **`rules`** -- validation rules that run only after the schema passes
|
|
11
11
|
|
|
12
|
-
If `params:`
|
|
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
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -17,6 +17,8 @@ If `params:` is not passed to the action at all, validation is skipped entirely
|
|
|
17
17
|
|
|
18
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
19
|
|
|
20
|
+
Each action calls **`params_schema` at most once** — a second call raises **`ArgumentError`**.
|
|
21
|
+
|
|
20
22
|
```ruby
|
|
21
23
|
class Users::CreateAction
|
|
22
24
|
include ActionFigure[:jsend]
|
|
@@ -100,6 +102,8 @@ end
|
|
|
100
102
|
ArgumentError: rules requires params_schema to be defined
|
|
101
103
|
```
|
|
102
104
|
|
|
105
|
+
- `params_schema` **may only be called once per action class**. Calling it again raises **`ArgumentError`**, so your schema cannot be replaced in a way that could silently confuse or strand an existing **`rules`** block.
|
|
106
|
+
|
|
103
107
|
- Inside a rule block, access validated values with `values[:field]`.
|
|
104
108
|
- 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
109
|
- Multiple rules can target the same field. All rules run even if earlier ones fail -- errors accumulate.
|
data/lib/action_figure/core.rb
CHANGED
|
@@ -36,13 +36,18 @@ module ActionFigure
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# DSL class methods extended into action classes: params_schema, rules, entry_point
|
|
39
|
+
# DSL class methods extended into action classes: params_schema, rules, entry_point.
|
|
40
40
|
#
|
|
41
41
|
# Note: ActionFigure does not support class inheritance. +params_schema+, +rules+, and
|
|
42
42
|
# +entry_point+ store state in class-level instance variables that are not inherited by
|
|
43
43
|
# subclasses. Define each action class independently.
|
|
44
44
|
module ClassMethods
|
|
45
45
|
def params_schema(&block)
|
|
46
|
+
if @params_schema_block
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"params_schema already defined — each action class may declare only one schema"
|
|
49
|
+
end
|
|
50
|
+
|
|
46
51
|
@params_schema_block = block
|
|
47
52
|
@contract = nil
|
|
48
53
|
end
|
|
@@ -64,6 +69,7 @@ module ActionFigure
|
|
|
64
69
|
"each action class may declare only one entry point"
|
|
65
70
|
end
|
|
66
71
|
|
|
72
|
+
@explicit_entry_point = true
|
|
67
73
|
@entry_point_name = name
|
|
68
74
|
singleton_class.define_method(name) do |**kwargs|
|
|
69
75
|
notify { new.validated_call(**kwargs) }
|
|
@@ -78,14 +84,6 @@ module ActionFigure
|
|
|
78
84
|
value == :_unset ? @api_version : (@api_version = value)
|
|
79
85
|
end
|
|
80
86
|
|
|
81
|
-
def call(**)
|
|
82
|
-
if @entry_point_name
|
|
83
|
-
raise NoMethodError, "undefined method 'call' for #{self} (use '#{@entry_point_name}' instead)"
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
notify { new.validated_call(**) }
|
|
87
|
-
end
|
|
88
|
-
|
|
89
87
|
def contract
|
|
90
88
|
return nil unless @params_schema_block
|
|
91
89
|
|
|
@@ -99,6 +97,38 @@ module ActionFigure
|
|
|
99
97
|
yield
|
|
100
98
|
end
|
|
101
99
|
|
|
100
|
+
def method_added(name)
|
|
101
|
+
disallow_action_initialize(name)
|
|
102
|
+
|
|
103
|
+
return if @explicit_entry_point || !public_method_defined?(name)
|
|
104
|
+
return unless instance_method(name).owner == self
|
|
105
|
+
|
|
106
|
+
if @entry_point_name
|
|
107
|
+
raise IndeterminateEntryPointError,
|
|
108
|
+
"Multiple public methods defined in #{self}: " \
|
|
109
|
+
":#{@entry_point_name} and :#{name}. " \
|
|
110
|
+
"Either make one private or declare " \
|
|
111
|
+
"`entry_point :#{@entry_point_name}` to disambiguate."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@entry_point_name = name
|
|
115
|
+
singleton_class.define_method(name) do |**kwargs|
|
|
116
|
+
notify { new.validated_call(**kwargs) }
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
super
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def disallow_action_initialize(method_name)
|
|
123
|
+
return unless method_name == :initialize
|
|
124
|
+
return unless instance_method(:initialize).owner == self
|
|
125
|
+
|
|
126
|
+
raise InitializationNotSupportedError,
|
|
127
|
+
"#{self} must not define initialize — ActionFigure invokes new with no " \
|
|
128
|
+
"arguments. Pass dependencies via the entry method's keyword arguments " \
|
|
129
|
+
"or use class-level collaborators."
|
|
130
|
+
end
|
|
131
|
+
|
|
102
132
|
def build_contract
|
|
103
133
|
schema_block = @params_schema_block
|
|
104
134
|
rules_block = @rules_block
|
|
@@ -115,7 +145,7 @@ module ActionFigure
|
|
|
115
145
|
end
|
|
116
146
|
|
|
117
147
|
def entry_point_name
|
|
118
|
-
self.class.entry_point_name
|
|
148
|
+
self.class.entry_point_name
|
|
119
149
|
end
|
|
120
150
|
|
|
121
151
|
def contract
|
|
@@ -123,12 +153,12 @@ module ActionFigure
|
|
|
123
153
|
end
|
|
124
154
|
|
|
125
155
|
def validated_call(**kwargs)
|
|
126
|
-
|
|
156
|
+
kwargs = normalize_params(kwargs)
|
|
127
157
|
|
|
128
|
-
if kwargs.key?(:params)
|
|
129
|
-
|
|
158
|
+
if contract && kwargs.key?(:params)
|
|
159
|
+
validate_and_call(**kwargs)
|
|
130
160
|
else
|
|
131
|
-
|
|
161
|
+
public_send(entry_point_name, **kwargs)
|
|
132
162
|
end
|
|
133
163
|
end
|
|
134
164
|
|
|
@@ -138,7 +168,10 @@ module ActionFigure
|
|
|
138
168
|
private
|
|
139
169
|
|
|
140
170
|
def notify
|
|
141
|
-
payload = {
|
|
171
|
+
payload = {
|
|
172
|
+
action: name,
|
|
173
|
+
entry_point: entry_point_name
|
|
174
|
+
}
|
|
142
175
|
ActiveSupport::Notifications.instrument("process.action_figure", payload) do
|
|
143
176
|
result = yield
|
|
144
177
|
payload[:status] = result[:status]
|
|
@@ -149,32 +182,54 @@ module ActionFigure
|
|
|
149
182
|
|
|
150
183
|
private
|
|
151
184
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
185
|
+
def normalize_params(kwargs)
|
|
186
|
+
return kwargs unless contract
|
|
187
|
+
|
|
188
|
+
raw = kwargs[:params]
|
|
189
|
+
return kwargs unless raw.respond_to?(:to_unsafe_h)
|
|
190
|
+
|
|
191
|
+
kwargs.merge(params: raw.to_unsafe_h)
|
|
192
|
+
end
|
|
155
193
|
|
|
156
|
-
|
|
194
|
+
def validate_and_call(**kwargs)
|
|
195
|
+
result = contract.call(kwargs[:params])
|
|
157
196
|
|
|
158
197
|
return UnprocessableContent(errors: result.errors.to_h) if result.failure?
|
|
159
198
|
|
|
160
|
-
extra_params_error = check_extra_params(
|
|
199
|
+
extra_params_error = check_extra_params(kwargs[:params], result)
|
|
161
200
|
return extra_params_error if extra_params_error
|
|
162
201
|
|
|
163
|
-
public_send(entry_point_name, **kwargs, params: result.to_h)
|
|
202
|
+
public_send(entry_point_name, **kwargs.except(:params), params: result.to_h)
|
|
164
203
|
end
|
|
165
204
|
|
|
166
205
|
def check_extra_params(raw_params, result)
|
|
167
206
|
return unless ActionFigure.configuration.whiny_extra_params
|
|
168
207
|
|
|
169
|
-
|
|
170
|
-
return if
|
|
208
|
+
errors = find_extra_keys(raw_params, result.to_h)
|
|
209
|
+
return if errors.empty?
|
|
171
210
|
|
|
172
|
-
errors = extra_keys.to_h { |k| [k, ["is not allowed"]] }
|
|
173
211
|
UnprocessableContent(errors: errors)
|
|
174
212
|
end
|
|
175
213
|
|
|
176
|
-
def
|
|
177
|
-
|
|
214
|
+
def find_extra_keys(raw, validated, prefix = nil)
|
|
215
|
+
top_level = extra_keys_at_level(raw, validated, prefix)
|
|
216
|
+
nested = nested_extra_keys(raw, validated, prefix)
|
|
217
|
+
top_level.merge(nested)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def extra_keys_at_level(raw, validated, prefix)
|
|
221
|
+
(raw.keys.map(&:to_sym) - validated.keys).to_h do |k|
|
|
222
|
+
[prefix ? :"#{prefix}.#{k}" : k, ["is not allowed"]]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def nested_extra_keys(raw, validated, prefix)
|
|
227
|
+
validated.each_with_object({}) do |(key, value), errors|
|
|
228
|
+
next unless value.is_a?(Hash) && raw[key].is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
nested_prefix = [prefix, key].compact.join(".")
|
|
231
|
+
errors.merge!(find_extra_keys(raw[key], value, nested_prefix))
|
|
232
|
+
end
|
|
178
233
|
end
|
|
179
234
|
end
|
|
180
235
|
end
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "concurrent/map"
|
|
4
|
+
|
|
3
5
|
module ActionFigure
|
|
4
6
|
# Provides formatter registration and lookup for ActionFigure.
|
|
5
7
|
module FormatRegistry
|
|
6
8
|
# Stores the mapping of format names to formatter modules.
|
|
7
9
|
class Formats
|
|
8
10
|
def initialize
|
|
9
|
-
@formats =
|
|
11
|
+
@formats = Concurrent::Map.new
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def register_formatter(**formatters)
|
|
13
|
-
@formats
|
|
15
|
+
formatters.each { |name, mod| @formats[name] = mod }
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def fetch(name)
|
|
@@ -5,7 +5,9 @@ module ActionFigure
|
|
|
5
5
|
# Include this in your formatter module to get a NoContent default
|
|
6
6
|
# and to signal that your module implements the formatter interface.
|
|
7
7
|
module Formatter
|
|
8
|
-
|
|
8
|
+
# Response helper names every formatter must define (+NoContent+ lives on +Formatter+, not required here).
|
|
9
|
+
# Update every built-in formatter when you extend this list; +register_formatter+ validates against it at load time.
|
|
10
|
+
REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired].freeze
|
|
9
11
|
|
|
10
12
|
def NoContent
|
|
11
13
|
{ status: :no_content }
|
|
@@ -3,23 +3,25 @@
|
|
|
3
3
|
module ActionFigure
|
|
4
4
|
module Formatters
|
|
5
5
|
# Implements Rails-style response helpers for use in action classes.
|
|
6
|
-
#
|
|
6
|
+
# Success responses use a { data: } envelope; errors live under an "errors" key on failure.
|
|
7
7
|
module Default
|
|
8
8
|
include ActionFigure::Formatter
|
|
9
9
|
|
|
10
10
|
def Ok(resource:, meta: nil)
|
|
11
|
-
body =
|
|
11
|
+
body = { data: resource }
|
|
12
|
+
body[:meta] = meta if meta
|
|
12
13
|
{ json: body, status: :ok }
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def Created(resource:, meta: nil)
|
|
16
|
-
body =
|
|
17
|
+
body = { data: resource }
|
|
18
|
+
body[:meta] = meta if meta
|
|
17
19
|
{ json: body, status: :created }
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def Accepted(resource: nil, meta: nil)
|
|
21
|
-
body =
|
|
22
|
-
body =
|
|
23
|
+
body = { data: resource }
|
|
24
|
+
body[:meta] = meta if meta
|
|
23
25
|
{ json: body, status: :accepted }
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -34,6 +36,14 @@ module ActionFigure
|
|
|
34
36
|
def Forbidden(errors:)
|
|
35
37
|
{ json: { errors: errors }, status: :forbidden }
|
|
36
38
|
end
|
|
39
|
+
|
|
40
|
+
def Conflict(errors:)
|
|
41
|
+
{ json: { errors: errors }, status: :conflict }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def PaymentRequired(errors:)
|
|
45
|
+
{ json: { errors: errors }, status: :payment_required }
|
|
46
|
+
end
|
|
37
47
|
end
|
|
38
48
|
end
|
|
39
49
|
end
|
|
@@ -36,6 +36,14 @@ module ActionFigure
|
|
|
36
36
|
def Forbidden(errors:)
|
|
37
37
|
{ json: { status: "fail", data: errors }, status: :forbidden }
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
def Conflict(errors:)
|
|
41
|
+
{ json: { status: "fail", data: errors }, status: :conflict }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def PaymentRequired(errors:)
|
|
45
|
+
{ json: { status: "fail", data: errors }, status: :payment_required }
|
|
46
|
+
end
|
|
39
47
|
end
|
|
40
48
|
end
|
|
41
49
|
end
|