action_figure 0.5.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.
- checksums.yaml +4 -4
- data/README.md +115 -143
- data/docs/actions.md +51 -59
- data/docs/custom-formatters.md +14 -4
- data/docs/response-formatters.md +159 -3
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +12 -10
- data/docs/validation.md +1 -1
- data/lib/action_figure/core.rb +44 -24
- data/lib/action_figure/format_registry.rb +4 -2
- data/lib/action_figure/formatter.rb +1 -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 +3 -5
- data/lib/action_figure/formatters/json_api.rb +8 -0
- 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 +5 -3
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +2 -1
- data/sig/action_figure.rbs +157 -1
- metadata +2 -1
data/docs/custom-formatters.md
CHANGED
|
@@ -11,7 +11,7 @@ A formatter is a module that includes `ActionFigure::Formatter` and defines meth
|
|
|
11
11
|
Including `ActionFigure::Formatter` gives you:
|
|
12
12
|
|
|
13
13
|
- A default `NoContent` implementation that returns `{ status: :no_content }`.
|
|
14
|
-
- A contract enforced at registration time: your module **must** define all
|
|
14
|
+
- A contract enforced at registration time: your module **must** define all 8 required methods.
|
|
15
15
|
|
|
16
16
|
The required methods are:
|
|
17
17
|
|
|
@@ -23,6 +23,8 @@ The required methods are:
|
|
|
23
23
|
| `UnprocessableContent` | Validation or schema rule failure |
|
|
24
24
|
| `NotFound` | Resource not found |
|
|
25
25
|
| `Forbidden` | Authorization failure |
|
|
26
|
+
| `Conflict` | Resource state conflict or duplicate |
|
|
27
|
+
| `PaymentRequired` | Business billing or quota constraint |
|
|
26
28
|
|
|
27
29
|
`NoContent` is provided by the base module and does not need to be defined, but you can override it if your format requires a different shape.
|
|
28
30
|
|
|
@@ -63,10 +65,18 @@ module WrappedFormatter
|
|
|
63
65
|
def Forbidden(errors:)
|
|
64
66
|
{ json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
|
|
65
67
|
end
|
|
68
|
+
|
|
69
|
+
def Conflict(errors:)
|
|
70
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :conflict }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def PaymentRequired(errors:)
|
|
74
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :payment_required }
|
|
75
|
+
end
|
|
66
76
|
end
|
|
67
77
|
```
|
|
68
78
|
|
|
69
|
-
All
|
|
79
|
+
All 8 required methods are defined. Success methods accept `resource:` and optionally `meta:`, while failure methods accept `errors:`. The `NoContent` method is inherited from the base `ActionFigure::Formatter` module and returns `{ status: :no_content }` with no JSON body -- override it if your format requires a different shape.
|
|
70
80
|
|
|
71
81
|
## Registering Your Formatter
|
|
72
82
|
|
|
@@ -103,7 +113,7 @@ Registration is not just bookkeeping -- ActionFigure validates every formatter m
|
|
|
103
113
|
|
|
104
114
|
```ruby
|
|
105
115
|
ActionFigure::Formatter::REQUIRED_METHODS
|
|
106
|
-
# => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden]
|
|
116
|
+
# => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden, :Conflict, :PaymentRequired]
|
|
107
117
|
```
|
|
108
118
|
|
|
109
119
|
If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent:
|
|
@@ -119,7 +129,7 @@ end
|
|
|
119
129
|
|
|
120
130
|
ActionFigure.register_formatter(incomplete: IncompleteFormatter)
|
|
121
131
|
# => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted,
|
|
122
|
-
# UnprocessableContent, NotFound, Forbidden
|
|
132
|
+
# UnprocessableContent, NotFound, Forbidden, Conflict, PaymentRequired
|
|
123
133
|
```
|
|
124
134
|
|
|
125
135
|
Validation is **atomic** when registering multiple formatters at once. If any single module in the batch fails validation, none of them are registered -- this ensures your registry always remains in a consistent state.
|
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,8 @@ 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` |
|
|
36
38
|
|
|
37
39
|
All assertions accept an optional second argument for a custom failure message:
|
|
38
40
|
|
|
@@ -70,6 +72,8 @@ require "action_figure/testing/rspec"
|
|
|
70
72
|
| `be_UnprocessableContent` | `:unprocessable_content` |
|
|
71
73
|
| `be_NotFound` | `:not_found` |
|
|
72
74
|
| `be_Forbidden` | `:forbidden` |
|
|
75
|
+
| `be_Conflict` | `:conflict` |
|
|
76
|
+
| `be_PaymentRequired` | `:payment_required` |
|
|
73
77
|
|
|
74
78
|
Matchers support negation:
|
|
75
79
|
|
|
@@ -101,7 +105,7 @@ class Users::CreateActionTest < Minitest::Test
|
|
|
101
105
|
include ActionFigure::Testing::Minitest
|
|
102
106
|
|
|
103
107
|
def test_creates_a_user
|
|
104
|
-
result = Users::CreateAction.
|
|
108
|
+
result = Users::CreateAction.create(params: { email: "jane@example.com", name: "Jane" })
|
|
105
109
|
|
|
106
110
|
assert_Ok(result)
|
|
107
111
|
assert_equal "jane@example.com", result[:json][:data][:email]
|
|
@@ -119,7 +123,7 @@ class Users::CreateActionTest < Minitest::Test
|
|
|
119
123
|
include ActionFigure::Testing::Minitest
|
|
120
124
|
|
|
121
125
|
def test_rejects_missing_email
|
|
122
|
-
result = Users::CreateAction.
|
|
126
|
+
result = Users::CreateAction.create(params: { name: "Jane" })
|
|
123
127
|
|
|
124
128
|
assert_UnprocessableContent(result)
|
|
125
129
|
assert_includes result[:json][:data][:email], "is missing"
|
|
@@ -137,7 +141,7 @@ class Posts::CreateActionTest < Minitest::Test
|
|
|
137
141
|
|
|
138
142
|
def test_creates_a_post_for_the_current_user
|
|
139
143
|
user = users(:jane)
|
|
140
|
-
result = Posts::CreateAction.
|
|
144
|
+
result = Posts::CreateAction.create(params: { title: "Hello", body: "World" }, current_user: user)
|
|
141
145
|
|
|
142
146
|
assert_Created(result)
|
|
143
147
|
assert_equal user.id, result[:json][:data][:author_id]
|
|
@@ -145,9 +149,9 @@ class Posts::CreateActionTest < Minitest::Test
|
|
|
145
149
|
end
|
|
146
150
|
```
|
|
147
151
|
|
|
148
|
-
### Testing a
|
|
152
|
+
### Testing an Action with a Named Method
|
|
149
153
|
|
|
150
|
-
|
|
154
|
+
Call the action using its discovered method name:
|
|
151
155
|
|
|
152
156
|
```ruby
|
|
153
157
|
class Products::SearchActionTest < Minitest::Test
|
|
@@ -156,8 +160,6 @@ class Products::SearchActionTest < Minitest::Test
|
|
|
156
160
|
# class SearchAction
|
|
157
161
|
# include ActionFigure[:jsend]
|
|
158
162
|
#
|
|
159
|
-
# entry_point :search
|
|
160
|
-
#
|
|
161
163
|
# params_schema do
|
|
162
164
|
# required(:query).filled(:string)
|
|
163
165
|
# end
|
|
@@ -187,14 +189,14 @@ class Sessions::DestroyActionTest < Minitest::Test
|
|
|
187
189
|
# class Sessions::DestroyAction
|
|
188
190
|
# include ActionFigure[:jsend]
|
|
189
191
|
#
|
|
190
|
-
# def
|
|
192
|
+
# def destroy(session:)
|
|
191
193
|
# session.destroy!
|
|
192
194
|
# NoContent()
|
|
193
195
|
# end
|
|
194
196
|
# end
|
|
195
197
|
def test_destroys_the_session
|
|
196
198
|
session = sessions(:active)
|
|
197
|
-
result = Sessions::DestroyAction.
|
|
199
|
+
result = Sessions::DestroyAction.destroy(session: session)
|
|
198
200
|
|
|
199
201
|
assert_NoContent(result)
|
|
200
202
|
end
|
|
@@ -224,7 +226,7 @@ result.failure? # => true
|
|
|
224
226
|
result.errors.to_h # => { email: ["must be filled"] }
|
|
225
227
|
```
|
|
226
228
|
|
|
227
|
-
This runs both the schema and any `rules` defined on the action -- the same validation pipeline that
|
|
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.
|
|
228
230
|
|
|
229
231
|
Actions that do not define a `params_schema` return `nil` from `.contract`.
|
|
230
232
|
|
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
|
|
data/lib/action_figure/core.rb
CHANGED
|
@@ -36,13 +36,19 @@ 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 && @rules_block
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"params_schema already defined with rules — " \
|
|
49
|
+
"redefining it would silently drop the existing rules block"
|
|
50
|
+
end
|
|
51
|
+
|
|
46
52
|
@params_schema_block = block
|
|
47
53
|
@contract = nil
|
|
48
54
|
end
|
|
@@ -64,6 +70,7 @@ module ActionFigure
|
|
|
64
70
|
"each action class may declare only one entry point"
|
|
65
71
|
end
|
|
66
72
|
|
|
73
|
+
@explicit_entry_point = true
|
|
67
74
|
@entry_point_name = name
|
|
68
75
|
singleton_class.define_method(name) do |**kwargs|
|
|
69
76
|
notify { new.validated_call(**kwargs) }
|
|
@@ -78,14 +85,6 @@ module ActionFigure
|
|
|
78
85
|
value == :_unset ? @api_version : (@api_version = value)
|
|
79
86
|
end
|
|
80
87
|
|
|
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
88
|
def contract
|
|
90
89
|
return nil unless @params_schema_block
|
|
91
90
|
|
|
@@ -99,6 +98,27 @@ module ActionFigure
|
|
|
99
98
|
yield
|
|
100
99
|
end
|
|
101
100
|
|
|
101
|
+
def method_added(name)
|
|
102
|
+
return if @explicit_entry_point
|
|
103
|
+
return unless public_method_defined?(name)
|
|
104
|
+
return unless instance_method(name).owner == self
|
|
105
|
+
|
|
106
|
+
if @entry_point_name
|
|
107
|
+
raise IndeterminantEntryPointError,
|
|
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
|
+
|
|
102
122
|
def build_contract
|
|
103
123
|
schema_block = @params_schema_block
|
|
104
124
|
rules_block = @rules_block
|
|
@@ -115,7 +135,7 @@ module ActionFigure
|
|
|
115
135
|
end
|
|
116
136
|
|
|
117
137
|
def entry_point_name
|
|
118
|
-
self.class.entry_point_name
|
|
138
|
+
self.class.entry_point_name
|
|
119
139
|
end
|
|
120
140
|
|
|
121
141
|
def contract
|
|
@@ -123,12 +143,12 @@ module ActionFigure
|
|
|
123
143
|
end
|
|
124
144
|
|
|
125
145
|
def validated_call(**kwargs)
|
|
126
|
-
|
|
146
|
+
kwargs = normalize_params(kwargs)
|
|
127
147
|
|
|
128
|
-
if kwargs.key?(:params)
|
|
129
|
-
|
|
148
|
+
if contract && kwargs.key?(:params)
|
|
149
|
+
validate_and_call(**kwargs)
|
|
130
150
|
else
|
|
131
|
-
|
|
151
|
+
public_send(entry_point_name, **kwargs)
|
|
132
152
|
end
|
|
133
153
|
end
|
|
134
154
|
|
|
@@ -149,18 +169,22 @@ module ActionFigure
|
|
|
149
169
|
|
|
150
170
|
private
|
|
151
171
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
def normalize_params(kwargs)
|
|
173
|
+
raw = kwargs[:params]
|
|
174
|
+
return kwargs unless raw.respond_to?(:to_unsafe_h)
|
|
175
|
+
|
|
176
|
+
kwargs.merge(params: raw.to_unsafe_h)
|
|
177
|
+
end
|
|
155
178
|
|
|
156
|
-
|
|
179
|
+
def validate_and_call(**kwargs)
|
|
180
|
+
result = contract.call(kwargs[:params])
|
|
157
181
|
|
|
158
182
|
return UnprocessableContent(errors: result.errors.to_h) if result.failure?
|
|
159
183
|
|
|
160
|
-
extra_params_error = check_extra_params(
|
|
184
|
+
extra_params_error = check_extra_params(kwargs[:params], result)
|
|
161
185
|
return extra_params_error if extra_params_error
|
|
162
186
|
|
|
163
|
-
public_send(entry_point_name, **kwargs, params: result.to_h)
|
|
187
|
+
public_send(entry_point_name, **kwargs.except(:params), params: result.to_h)
|
|
164
188
|
end
|
|
165
189
|
|
|
166
190
|
def check_extra_params(raw_params, result)
|
|
@@ -172,9 +196,5 @@ module ActionFigure
|
|
|
172
196
|
errors = extra_keys.to_h { |k| [k, ["is not allowed"]] }
|
|
173
197
|
UnprocessableContent(errors: errors)
|
|
174
198
|
end
|
|
175
|
-
|
|
176
|
-
def call_without_params(**)
|
|
177
|
-
public_send(entry_point_name, **)
|
|
178
|
-
end
|
|
179
199
|
end
|
|
180
200
|
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,7 @@ 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
|
-
REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden].freeze
|
|
8
|
+
REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired].freeze
|
|
9
9
|
|
|
10
10
|
def NoContent
|
|
11
11
|
{ 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
|
|
@@ -6,13 +6,11 @@ module ActionFigure
|
|
|
6
6
|
# Simple resource serialization
|
|
7
7
|
class Resource
|
|
8
8
|
def self.serialize(resource)
|
|
9
|
-
if resource.
|
|
10
|
-
resource
|
|
11
|
-
elsif resource.respond_to?(:attributes)
|
|
9
|
+
if resource.respond_to?(:attributes)
|
|
12
10
|
serialize_one(resource)
|
|
13
|
-
elsif resource.respond_to?(:each)
|
|
11
|
+
elsif !resource.is_a?(Hash) && resource.respond_to?(:each)
|
|
14
12
|
resource.map { |r| serialize(r) }
|
|
15
|
-
else
|
|
13
|
+
else
|
|
16
14
|
resource
|
|
17
15
|
end
|
|
18
16
|
end
|
|
@@ -38,6 +38,14 @@ module ActionFigure
|
|
|
38
38
|
{ json: { errors: convert_errors(errors, "403") }, status: :forbidden }
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def Conflict(errors:)
|
|
42
|
+
{ json: { errors: convert_errors(errors, "409") }, status: :conflict }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def PaymentRequired(errors:)
|
|
46
|
+
{ json: { errors: convert_errors(errors, "402") }, status: :payment_required }
|
|
47
|
+
end
|
|
48
|
+
|
|
41
49
|
private
|
|
42
50
|
|
|
43
51
|
def convert_errors(errors, status)
|
|
@@ -36,6 +36,14 @@ module ActionFigure
|
|
|
36
36
|
def Forbidden(errors:)
|
|
37
37
|
{ json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
def Conflict(errors:)
|
|
41
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :conflict }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def PaymentRequired(errors:)
|
|
45
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :payment_required }
|
|
46
|
+
end
|
|
39
47
|
end
|
|
40
48
|
end
|
|
41
49
|
end
|