action_figure 0.6.2 → 0.7.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/CHANGELOG.md +35 -0
- data/README.md +4 -3
- data/docs/custom-formatters.md +34 -34
- data/docs/integration-patterns.md +20 -18
- data/docs/problem-details.md +113 -0
- data/docs/response-formatters.md +183 -17
- data/docs/status-codes.md +5 -3
- data/docs/testing.md +65 -0
- data/lib/action_figure/error_registry.rb +108 -0
- data/lib/action_figure/formatter.rb +12 -5
- data/lib/action_figure/formatters/default.rb +2 -18
- data/lib/action_figure/formatters/jsend.rb +2 -18
- data/lib/action_figure/formatters/json_api.rb +3 -18
- data/lib/action_figure/formatters/rfc_9457.rb +143 -0
- data/lib/action_figure/formatters/wrapped.rb +2 -18
- data/lib/action_figure/testing/minitest.rb +75 -22
- data/lib/action_figure/testing/rspec.rb +53 -14
- data/lib/action_figure/testing/statuses.rb +46 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +7 -6
- data/sig/action_figure.rbs +81 -26
- metadata +19 -1
data/docs/response-formatters.md
CHANGED
|
@@ -12,7 +12,7 @@ class UsersController < ApplicationController
|
|
|
12
12
|
end
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
The **formatter** determines the shape of the JSON envelope wrapping your data. ActionFigure ships with
|
|
15
|
+
The **formatter** determines the shape of the JSON envelope wrapping your data. ActionFigure ships with five built-in formatters: Default, JSend, JSON:API, Wrapped, and RFC 9457.
|
|
16
16
|
|
|
17
17
|
## Choosing a Format
|
|
18
18
|
|
|
@@ -39,6 +39,11 @@ class Users::CreateAction
|
|
|
39
39
|
include ActionFigure[:wrapped]
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Explicit RFC 9457
|
|
43
|
+
class Users::CreateAction
|
|
44
|
+
include ActionFigure[:rfc_9457]
|
|
45
|
+
end
|
|
46
|
+
|
|
42
47
|
# Uses the configured default (Default unless changed)
|
|
43
48
|
class Users::CreateAction
|
|
44
49
|
include ActionFigure
|
|
@@ -47,7 +52,7 @@ end
|
|
|
47
52
|
|
|
48
53
|
## Response Helpers
|
|
49
54
|
|
|
50
|
-
Every formatter implements the same
|
|
55
|
+
Every formatter implements the same twelve response helpers. Eleven return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`.
|
|
51
56
|
|
|
52
57
|
| Helper | HTTP Status | When to Use |
|
|
53
58
|
|---------------------------------|--------------------------|--------------------------------------------------|
|
|
@@ -59,7 +64,10 @@ Every formatter implements the same nine response helpers. Eight return a hash w
|
|
|
59
64
|
| `Forbidden(errors:)` | `403 Forbidden` | Authorization failure |
|
|
60
65
|
| `NotFound(errors:)` | `404 Not Found` | Resource not found |
|
|
61
66
|
| `Conflict(errors:)` | `409 Conflict` | Resource state conflict or duplicate |
|
|
67
|
+
| `Gone(errors:)` | `410 Gone` | Resource permanently deleted (not just 404) |
|
|
62
68
|
| `UnprocessableContent(errors:)` | `422 Unprocessable Content` | Validation failures |
|
|
69
|
+
| `Locked(errors:)` | `423 Locked` | Resource locked by another process |
|
|
70
|
+
| `UnavailableForLegalReasons(errors:)` | `451 Unavailable For Legal Reasons` | Resource censored for legal/regional reasons |
|
|
63
71
|
|
|
64
72
|
`NoContent` is shared across all formatters and is defined in the base `Formatter` module. It returns `{ status: :no_content }` with no JSON body.
|
|
65
73
|
|
|
@@ -67,11 +75,11 @@ ActionFigure provides helpers for the status codes most commonly returned by act
|
|
|
67
75
|
|
|
68
76
|
## Default Format
|
|
69
77
|
|
|
70
|
-
The default formatter produces Rails-style responses: the resource
|
|
78
|
+
The default formatter produces Rails-style responses: the resource lives under a `"data"` key on success, and errors live under an `"errors"` key on failure. This is the configured default format — bare `include ActionFigure` uses it unless you change `config.format`.
|
|
71
79
|
|
|
72
80
|
### Success Responses
|
|
73
81
|
|
|
74
|
-
The resource you pass
|
|
82
|
+
The resource you pass is wrapped in a `{ "data": ... }` envelope.
|
|
75
83
|
|
|
76
84
|
**`Ok` -- returning a single resource:**
|
|
77
85
|
|
|
@@ -85,9 +93,11 @@ end
|
|
|
85
93
|
|
|
86
94
|
```json
|
|
87
95
|
{
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
"data": {
|
|
97
|
+
"id": 1,
|
|
98
|
+
"name": "Jane Doe",
|
|
99
|
+
"email": "jane@example.com"
|
|
100
|
+
}
|
|
91
101
|
}
|
|
92
102
|
```
|
|
93
103
|
|
|
@@ -105,15 +115,17 @@ end
|
|
|
105
115
|
|
|
106
116
|
```json
|
|
107
117
|
{
|
|
108
|
-
"
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
"data": {
|
|
119
|
+
"id": 42,
|
|
120
|
+
"name": "Jane Doe",
|
|
121
|
+
"email": "jane@example.com"
|
|
122
|
+
}
|
|
111
123
|
}
|
|
112
124
|
```
|
|
113
125
|
|
|
114
126
|
**`Ok` -- with metadata:**
|
|
115
127
|
|
|
116
|
-
When `meta:` is provided,
|
|
128
|
+
When `meta:` is provided, `"meta"` sits alongside `"data"`:
|
|
117
129
|
|
|
118
130
|
```ruby
|
|
119
131
|
def call(params:)
|
|
@@ -135,10 +147,10 @@ end
|
|
|
135
147
|
}
|
|
136
148
|
```
|
|
137
149
|
|
|
138
|
-
Without `meta:`, the resource is the entire body. With `meta:`, the response becomes `{ "data": resource, "meta": meta }`.
|
|
139
|
-
|
|
140
150
|
**`Accepted` -- with no resource:**
|
|
141
151
|
|
|
152
|
+
Without a resource, `"data"` is `null` (the key is always present):
|
|
153
|
+
|
|
142
154
|
```ruby
|
|
143
155
|
def call(params:)
|
|
144
156
|
OrderFulfillmentJob.perform_later(params[:order_id])
|
|
@@ -147,7 +159,9 @@ end
|
|
|
147
159
|
```
|
|
148
160
|
|
|
149
161
|
```json
|
|
150
|
-
{
|
|
162
|
+
{
|
|
163
|
+
"data": null
|
|
164
|
+
}
|
|
151
165
|
```
|
|
152
166
|
|
|
153
167
|
**`Accepted` -- with a resource:**
|
|
@@ -162,8 +176,10 @@ end
|
|
|
162
176
|
|
|
163
177
|
```json
|
|
164
178
|
{
|
|
165
|
-
"
|
|
166
|
-
|
|
179
|
+
"data": {
|
|
180
|
+
"order_id": 7,
|
|
181
|
+
"status": "processing"
|
|
182
|
+
}
|
|
167
183
|
}
|
|
168
184
|
```
|
|
169
185
|
|
|
@@ -985,9 +1001,159 @@ end
|
|
|
985
1001
|
}
|
|
986
1002
|
```
|
|
987
1003
|
|
|
1004
|
+
## RFC 9457 Format
|
|
1005
|
+
|
|
1006
|
+
The RFC 9457 formatter renders errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents (`Content-Type: application/problem+json`). Success responses use the same `type`/`title` vocabulary, though RFC 9457 itself only covers errors.
|
|
1007
|
+
|
|
1008
|
+
### Success Responses
|
|
1009
|
+
|
|
1010
|
+
Success responses place the resource under a key derived from its class name and carry `type` and `title` members.
|
|
1011
|
+
|
|
1012
|
+
**`Created` -- returning a model resource:**
|
|
1013
|
+
|
|
1014
|
+
```ruby
|
|
1015
|
+
def call(params:)
|
|
1016
|
+
user = User.create(params)
|
|
1017
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
1018
|
+
|
|
1019
|
+
Created(
|
|
1020
|
+
resource: user,
|
|
1021
|
+
type: "https://api.example.com/success/user-created",
|
|
1022
|
+
title: "User created"
|
|
1023
|
+
)
|
|
1024
|
+
end
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
```json
|
|
1028
|
+
{
|
|
1029
|
+
"type": "https://api.example.com/success/user-created",
|
|
1030
|
+
"title": "User created",
|
|
1031
|
+
"user": { "id": 42, "name": "Jane Doe", "email": "jane@example.com" }
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
Without explicit `type:` and `title:`, the formatter derives them from the resource class and status — `User` + `Created` → `type: "user-created"`, `title: "User created"`. For `Ok`, `User` + `Ok` → `type: "user-ok"`, `title: "User ok"` — deliberately awkward. Pass explicit values your clients can rely on.
|
|
1036
|
+
|
|
1037
|
+
Hash and primitive resources fall back to the key `data` and the name `resource`:
|
|
1038
|
+
|
|
1039
|
+
```ruby
|
|
1040
|
+
Created(resource: { order_id: 7, status: "queued" })
|
|
1041
|
+
# -> type: "resource-created", title: "Resource created", data: { ... }
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
Override the derived name with `as:`:
|
|
1045
|
+
|
|
1046
|
+
```ruby
|
|
1047
|
+
Created(resource: some_hash, as: :order)
|
|
1048
|
+
# -> type: "order-created", title: "Order created", order: { ... }
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
**`Accepted` -- with no resource:**
|
|
1052
|
+
|
|
1053
|
+
```ruby
|
|
1054
|
+
def call(params:)
|
|
1055
|
+
OrderFulfillmentJob.perform_later(params[:order_id])
|
|
1056
|
+
Accepted()
|
|
1057
|
+
end
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
```json
|
|
1061
|
+
{
|
|
1062
|
+
"type": "resource-accepted",
|
|
1063
|
+
"title": "Resource accepted"
|
|
1064
|
+
}
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
### Failure Responses
|
|
1068
|
+
|
|
1069
|
+
Failure responses produce RFC 9457 problem documents. All members except `type`, `title`, and `status` are optional.
|
|
1070
|
+
|
|
1071
|
+
**`UnprocessableContent` -- validation errors:**
|
|
1072
|
+
|
|
1073
|
+
`UnprocessableContent` defaults to `type: "unprocessable-content-error"`, so schema validation failures work without any configuration:
|
|
1074
|
+
|
|
1075
|
+
```ruby
|
|
1076
|
+
def call(params:)
|
|
1077
|
+
user = User.new(params)
|
|
1078
|
+
return UnprocessableContent(errors: user.errors.messages) unless user.save
|
|
1079
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
1080
|
+
Created(resource:, type: "https://api.example.com/success/user-created", title: "User created")
|
|
1081
|
+
end
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
```json
|
|
1085
|
+
{
|
|
1086
|
+
"type": "unprocessable-content-error",
|
|
1087
|
+
"title": "Unprocessable Content",
|
|
1088
|
+
"status": 422,
|
|
1089
|
+
"errors": {
|
|
1090
|
+
"email": ["has already been taken"],
|
|
1091
|
+
"name": ["can't be blank"]
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
Override `type:` and `title:` when you want a domain-specific URI:
|
|
1097
|
+
|
|
1098
|
+
```ruby
|
|
1099
|
+
UnprocessableContent(
|
|
1100
|
+
errors: user.errors.messages,
|
|
1101
|
+
type: "https://api.example.com/problems/validation-error",
|
|
1102
|
+
title: "Validation failed"
|
|
1103
|
+
)
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
**`NotFound` -- with `detail` and `instance`:**
|
|
1107
|
+
|
|
1108
|
+
```ruby
|
|
1109
|
+
def call(params:)
|
|
1110
|
+
user = User.find_by(id: params[:id])
|
|
1111
|
+
return NotFound(
|
|
1112
|
+
type: "https://api.example.com/problems/user-not-found",
|
|
1113
|
+
title: "User not found",
|
|
1114
|
+
detail: "No user with id #{params[:id]} exists.",
|
|
1115
|
+
instance: "/users/#{params[:id]}"
|
|
1116
|
+
) unless user
|
|
1117
|
+
Ok(resource: user, type: "https://api.example.com/success/user-ok", title: "User found")
|
|
1118
|
+
end
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
```json
|
|
1122
|
+
{
|
|
1123
|
+
"type": "https://api.example.com/problems/user-not-found",
|
|
1124
|
+
"title": "User not found",
|
|
1125
|
+
"status": 404,
|
|
1126
|
+
"detail": "No user with id 99 exists.",
|
|
1127
|
+
"instance": "/users/99"
|
|
1128
|
+
}
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
Extra kwargs become extension members in the problem document:
|
|
1132
|
+
|
|
1133
|
+
```ruby
|
|
1134
|
+
PaymentRequired(
|
|
1135
|
+
type: "https://api.example.com/problems/quota-exceeded",
|
|
1136
|
+
title: "Quota exceeded",
|
|
1137
|
+
balance: 0,
|
|
1138
|
+
limit: 100
|
|
1139
|
+
)
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
```json
|
|
1143
|
+
{
|
|
1144
|
+
"type": "https://api.example.com/problems/quota-exceeded",
|
|
1145
|
+
"title": "Quota exceeded",
|
|
1146
|
+
"status": 402,
|
|
1147
|
+
"balance": 0,
|
|
1148
|
+
"limit": 100
|
|
1149
|
+
}
|
|
1150
|
+
```
|
|
1151
|
+
|
|
1152
|
+
`UnprocessableContent` defaults to `type: "unprocessable-content-error"`. All other error helpers derive `type` from the action class and status — `Projects::FindAction` + `NotFound` → `"projects-find-not-found-error"`. Provide a stable URI; the default is intentionally unattractive.
|
|
1153
|
+
|
|
988
1154
|
## The `meta:` Keyword
|
|
989
1155
|
|
|
990
|
-
The `meta:` keyword argument is available on `Ok`, `Created`, and `Accepted`. It accepts any hash, which is included as a top-level `"meta"` key in all
|
|
1156
|
+
The `meta:` keyword argument is available on `Ok`, `Created`, and `Accepted`. It accepts any hash, which is included as a top-level `"meta"` key in all five formatters. When `meta:` is `nil` (the default), the key is omitted entirely from the response.
|
|
991
1157
|
|
|
992
1158
|
Common uses for `meta:`:
|
|
993
1159
|
|
data/docs/status-codes.md
CHANGED
|
@@ -14,7 +14,7 @@ Rows in **bold** are status codes with built-in formatter methods — action cla
|
|
|
14
14
|
| 407 | Proxy Auth Required | Perimeter | Infrastructure | Similar to 401, but for a proxy server. |
|
|
15
15
|
| 408 | Request Timeout | Perimeter | Server/Nginx | The client took too long to send the request. |
|
|
16
16
|
| **409** | **Conflict** | **Domain** | **Action Class** | **Resource already exists, or the state is in conflict.** |
|
|
17
|
-
| 410
|
|
17
|
+
| **410** | **Gone** | **Domain** | **Action Class** | **The resource is permanently deleted (not just 404).** |
|
|
18
18
|
| 411 | Length Required | Perimeter | Server/Rack | The request didn't specify a Content-Length. |
|
|
19
19
|
| 412 | Precondition Failed | Perimeter | Controller/Rack | If-Match headers don't match (usually for caching). |
|
|
20
20
|
| 413 | Payload Too Large | Perimeter | Server/Nginx | The request body is bigger than the server allows. |
|
|
@@ -25,11 +25,13 @@ Rows in **bold** are status codes with built-in formatter methods — action cla
|
|
|
25
25
|
| 418 | I'm a teapot | Domain | Action Class | An IETF April Fools joke (rarely used in production). |
|
|
26
26
|
| 421 | Misdirected Request | Perimeter | Infrastructure | The server can't produce a response for this connection. |
|
|
27
27
|
| **422** | **Unprocessable Content** | **Domain** | **Action Class** | **Semantic errors (validation, business rules).** |
|
|
28
|
-
| 423
|
|
28
|
+
| **423** | **Locked** | **Domain** | **Action Class** | **The resource is being accessed by another process.** |
|
|
29
29
|
| 424 | Failed Dependency | Domain | Action Class | The request failed due to a failure of a previous request. |
|
|
30
30
|
| 425 | Too Early | Perimeter | Server/Rack | The server is unwilling to process a request that might be replayed. |
|
|
31
31
|
| 426 | Upgrade Required | Perimeter | Server/Rack | The client must switch to a different protocol (e.g., TLS). |
|
|
32
32
|
| 428 | Precondition Required | Perimeter | Controller/Rack | The server requires the request to be conditional. |
|
|
33
33
|
| 429 | Too Many Requests | Perimeter | Rack::Attack | Infrastructure-level rate limiting (IP-based, etc.). |
|
|
34
34
|
| 431 | Request Header Fields Too Large | Perimeter | Server/Rack | HTTP headers are too large. |
|
|
35
|
-
| 451
|
|
35
|
+
| **451** | **Unavailable For Legal Reasons** | **Domain** | **Action Class** | **Resource censored/blocked for legal/regional reasons.** |
|
|
36
|
+
|
|
37
|
+
Any other status — including 5xx codes such as 502 Bad Gateway — can be added with `ActionFigure.register_error(:BadGateway, :bad_gateway)`. The status symbol is validated against Rack's status table at registration, so a typo raises `ArgumentError` at boot. 5xx codes are a deliberate opt-out from the domain/perimeter split and are not built in by default.
|
data/docs/testing.md
CHANGED
|
@@ -35,6 +35,11 @@ end
|
|
|
35
35
|
| `assert_Forbidden(result)` | `:forbidden` |
|
|
36
36
|
| `assert_Conflict(result)` | `:conflict` |
|
|
37
37
|
| `assert_PaymentRequired(result)` | `:payment_required` |
|
|
38
|
+
| `assert_Gone(result)` | `:gone` |
|
|
39
|
+
| `assert_Locked(result)` | `:locked` |
|
|
40
|
+
| `assert_UnavailableForLegalReasons(result)` | `:unavailable_for_legal_reasons` |
|
|
41
|
+
|
|
42
|
+
Each status assertion has a negated counterpart — **`refute_Ok`**, **`refute_Created`**, … — that passes when the status is anything *other* than the named one. Statuses added with `ActionFigure.register_error` get matching `assert_*`/`refute_*` helpers automatically, whether registered before or after this adapter loads.
|
|
38
43
|
|
|
39
44
|
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.
|
|
40
45
|
|
|
@@ -50,6 +55,18 @@ When a status assertion fails, the default message shows the expected and actual
|
|
|
50
55
|
Expected result status to be :ok, but got :unprocessable_content
|
|
51
56
|
```
|
|
52
57
|
|
|
58
|
+
### Asserting on the body
|
|
59
|
+
|
|
60
|
+
Use **`assert_action_json`** to match a (possibly nested) subset of **`result[:json]`** — the Minitest counterpart to RSpec's **`have_action_json`**. Nested Hashes match as subsets, and **`Regexp`** values match against strings:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
assert_action_json(result, status: "success")
|
|
64
|
+
assert_action_json(result, status: "success", data: { name: "Jane" })
|
|
65
|
+
assert_action_json(result, data: { email: /@example\.com\z/ })
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**`refute_action_json`** passes when the fragment does **not** match. Both fail with a clear message when given a non-result value or a hash missing the **`:json`** key.
|
|
69
|
+
|
|
53
70
|
---
|
|
54
71
|
|
|
55
72
|
## RSpec
|
|
@@ -78,7 +95,12 @@ require "action_figure/testing/rspec"
|
|
|
78
95
|
| `be_Forbidden` | `:forbidden` |
|
|
79
96
|
| `be_Conflict` | `:conflict` |
|
|
80
97
|
| `be_PaymentRequired` | `:payment_required` |
|
|
98
|
+
| `be_Gone` | `:gone` |
|
|
99
|
+
| `be_Locked` | `:locked` |
|
|
100
|
+
| `be_UnavailableForLegalReasons` | `:unavailable_for_legal_reasons` |
|
|
81
101
|
| `have_action_json` | `result[:json]` matches `a_hash_including(fragment)` |
|
|
102
|
+
| `accept_params(params)` | action class's contract accepts `params` |
|
|
103
|
+
| `reject_params(params)` | action class's contract rejects `params` (chain `.with_error_on(:field)`) |
|
|
82
104
|
|
|
83
105
|
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
106
|
|
|
@@ -279,6 +301,49 @@ class Users::CreateActionTest < Minitest::Test
|
|
|
279
301
|
end
|
|
280
302
|
```
|
|
281
303
|
|
|
304
|
+
### Contract assertion helpers
|
|
305
|
+
|
|
306
|
+
The testing adapters wrap the `.contract.call` boilerplate above in intention-revealing helpers. They are **formatter-agnostic** — they exercise the validation pipeline directly, so the same assertions work regardless of which formatter the action includes.
|
|
307
|
+
|
|
308
|
+
**Minitest** — the subject is the action class:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
class Users::CreateActionTest < Minitest::Test
|
|
312
|
+
include ActionFigure::Testing::Minitest
|
|
313
|
+
|
|
314
|
+
def test_accepts_valid_params
|
|
315
|
+
assert_valid_params(Users::CreateAction, { email: "jane@example.com", name: "Jane" })
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def test_requires_email
|
|
319
|
+
# passes when the contract rejects the params at all
|
|
320
|
+
assert_invalid_params(Users::CreateAction, { name: "Jane" })
|
|
321
|
+
|
|
322
|
+
# scope to a field: passes only when :email is among the errors
|
|
323
|
+
assert_invalid_params(Users::CreateAction, { name: "Jane" }, on: :email)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**RSpec** — the subject is the action class, not a result hash:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
RSpec.describe Users::CreateAction do
|
|
332
|
+
it "accepts valid params" do
|
|
333
|
+
expect(Users::CreateAction).to accept_params(email: "jane@example.com", name: "Jane")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it "requires email" do
|
|
337
|
+
expect(Users::CreateAction).to reject_params(name: "Jane")
|
|
338
|
+
expect(Users::CreateAction).to reject_params(name: "Jane").with_error_on(:email)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Both adapters raise a clear **`ArgumentError`** when the action class declares no **`params_schema`** (and therefore has no contract to validate against).
|
|
344
|
+
|
|
345
|
+
> **Validation errors vs. error bodies.** These helpers are the right tool for asserting *validation* behavior. There is no formatter-agnostic helper for **non-validation** error bodies (e.g. a `NotFound`/`Conflict` you return with a custom `errors:` payload) — each formatter stores those differently and the result hash carries no formatter identity. Assert those with a format-specific `assert_action_json` / `have_action_json`.
|
|
346
|
+
|
|
282
347
|
---
|
|
283
348
|
|
|
284
349
|
## Conventions
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/map"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module ActionFigure
|
|
7
|
+
# Central registry mapping error-helper names (e.g. +:NotFound+) to the Rack
|
|
8
|
+
# status symbol they render (e.g. +:not_found+). Single source of truth for
|
|
9
|
+
# formatter error helpers and the Minitest/RSpec status assertions.
|
|
10
|
+
#
|
|
11
|
+
# Extended into ActionFigure, so the public API is ActionFigure.error_statuses
|
|
12
|
+
# and ActionFigure.register_error.
|
|
13
|
+
module ErrorRegistry
|
|
14
|
+
# Built-in error statuses. Domain-owned 4xx only — no 5xx ships built-in.
|
|
15
|
+
DEFAULTS = {
|
|
16
|
+
UnprocessableContent: :unprocessable_content,
|
|
17
|
+
NotFound: :not_found,
|
|
18
|
+
Forbidden: :forbidden,
|
|
19
|
+
Conflict: :conflict,
|
|
20
|
+
PaymentRequired: :payment_required,
|
|
21
|
+
Gone: :gone,
|
|
22
|
+
Locked: :locked,
|
|
23
|
+
UnavailableForLegalReasons: :unavailable_for_legal_reasons
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Live module holding one generated helper per registered error status.
|
|
27
|
+
# ActionFigure::Formatter includes it, so the helpers reach every formatter
|
|
28
|
+
# module and every composed format module through method lookup — including
|
|
29
|
+
# classes that included a format module before a late +register_error+ call.
|
|
30
|
+
# A helper hand-defined on a formatter shadows the generated one, because
|
|
31
|
+
# the formatter sits ahead of this module in the ancestor chain.
|
|
32
|
+
module Helpers; end
|
|
33
|
+
|
|
34
|
+
def self.define_helper(name, status_symbol)
|
|
35
|
+
Helpers.define_method(name) do |errors: nil, **extras|
|
|
36
|
+
error_response(errors: errors, status: status_symbol, **extras)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DEFAULTS.each { |name, status| define_helper(name, status) }
|
|
41
|
+
|
|
42
|
+
# Populates the registry when ErrorRegistry is extended (at gem load,
|
|
43
|
+
# single-threaded), so no lazy initialization races with reader threads.
|
|
44
|
+
def self.extended(base)
|
|
45
|
+
registry = Concurrent::Map.new
|
|
46
|
+
DEFAULTS.each { |name, status| registry[name] = status }
|
|
47
|
+
base.instance_variable_set(:@error_status_registry, registry)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Statuses Rack itself doesn't map on every supported version:
|
|
51
|
+
# Rack < 3.1 knows 422 only as :unprocessable_entity, Rack >= 3.1 only as
|
|
52
|
+
# :unprocessable_content. Accept both everywhere.
|
|
53
|
+
STATUS_CODE_FALLBACKS = { unprocessable_content: 422, unprocessable_entity: 422 }.freeze
|
|
54
|
+
|
|
55
|
+
# A plain-Hash copy of the current registry (built-ins plus any registered).
|
|
56
|
+
def error_statuses
|
|
57
|
+
error_status_registry.each_pair.to_h
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Resolves a Rack-style status symbol to its numeric HTTP status code,
|
|
61
|
+
# raising ArgumentError for symbols no supported Rack version knows.
|
|
62
|
+
# Single resolution point for formatters and register_error validation.
|
|
63
|
+
def status_code_for(status_symbol)
|
|
64
|
+
Rack::Utils::SYMBOL_TO_STATUS_CODE[status_symbol] ||
|
|
65
|
+
STATUS_CODE_FALLBACKS[status_symbol] ||
|
|
66
|
+
raise(ArgumentError, "#{status_symbol.inspect} is not a known HTTP status symbol")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Registers an additional error status. Add-only: does not remove or override.
|
|
70
|
+
# Defines the generated helper on the live Helpers module (so it appears on
|
|
71
|
+
# every formatter and already-included action class immediately) and patches
|
|
72
|
+
# already-loaded test adapters.
|
|
73
|
+
def register_error(name, status_symbol)
|
|
74
|
+
name = name.to_sym
|
|
75
|
+
status_symbol = status_symbol.to_sym
|
|
76
|
+
validate_registration!(name, status_symbol)
|
|
77
|
+
|
|
78
|
+
existing = error_status_registry.put_if_absent(name, status_symbol)
|
|
79
|
+
if existing && existing != status_symbol
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"Error status #{name.inspect} is already registered as #{existing.inspect}; " \
|
|
82
|
+
"register_error is add-only and cannot override it"
|
|
83
|
+
end
|
|
84
|
+
return name if existing
|
|
85
|
+
|
|
86
|
+
ErrorRegistry.define_helper(name, status_symbol)
|
|
87
|
+
ActionFigure::Testing.define_error_helper(name, status_symbol) if defined?(ActionFigure::Testing)
|
|
88
|
+
name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
attr_reader :error_status_registry
|
|
94
|
+
|
|
95
|
+
# Rejects reserved helper names and unknown status symbols up front, so a
|
|
96
|
+
# bad registration fails at boot instead of at render time.
|
|
97
|
+
def validate_registration!(name, status_symbol)
|
|
98
|
+
reserved = Formatter::REQUIRED_METHODS + [:NoContent]
|
|
99
|
+
if reserved.include?(name)
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"#{name.inspect} is reserved by the formatter contract and cannot be " \
|
|
102
|
+
"registered as an error status"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
status_code_for(status_symbol)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "error_registry"
|
|
4
|
+
|
|
3
5
|
module ActionFigure
|
|
4
6
|
# Base module for ActionFigure response formatters.
|
|
5
|
-
# Include this in your formatter module to get a NoContent default
|
|
6
|
-
#
|
|
7
|
+
# Include this in your formatter module to get a NoContent default, the
|
|
8
|
+
# generated named error helpers (NotFound, Conflict, and every registered
|
|
9
|
+
# status), and to signal that your module implements the formatter interface.
|
|
7
10
|
module Formatter
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
include ErrorRegistry::Helpers
|
|
12
|
+
|
|
13
|
+
# Structural methods every formatter must define: the three success helpers plus
|
|
14
|
+
# the single error_response. Named error helpers are generated from
|
|
15
|
+
# ActionFigure.error_statuses onto ErrorRegistry::Helpers (included here),
|
|
16
|
+
# so they are NOT a per-formatter obligation.
|
|
17
|
+
REQUIRED_METHODS = %i[Ok Created Accepted error_response].freeze
|
|
11
18
|
|
|
12
19
|
def NoContent
|
|
13
20
|
{ status: :no_content }
|
|
@@ -25,24 +25,8 @@ module ActionFigure
|
|
|
25
25
|
{ json: body, status: :accepted }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
{ json: { errors: errors }, status:
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def NotFound(errors:)
|
|
33
|
-
{ json: { errors: errors }, status: :not_found }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def Forbidden(errors:)
|
|
37
|
-
{ json: { errors: errors }, status: :forbidden }
|
|
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 }
|
|
28
|
+
def error_response(errors:, status:)
|
|
29
|
+
{ json: { errors: errors }, status: status }
|
|
46
30
|
end
|
|
47
31
|
end
|
|
48
32
|
end
|
|
@@ -25,24 +25,8 @@ module ActionFigure
|
|
|
25
25
|
{ json: body, status: :accepted }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
{ json: { status: "fail", data: errors }, status:
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def NotFound(errors:)
|
|
33
|
-
{ json: { status: "fail", data: errors }, status: :not_found }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def Forbidden(errors:)
|
|
37
|
-
{ json: { status: "fail", data: errors }, status: :forbidden }
|
|
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 }
|
|
28
|
+
def error_response(errors:, status:)
|
|
29
|
+
{ json: { status: "fail", data: errors }, status: status }
|
|
46
30
|
end
|
|
47
31
|
end
|
|
48
32
|
end
|
|
@@ -26,24 +26,9 @@ module ActionFigure
|
|
|
26
26
|
{ json: body, status: :accepted }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def NotFound(errors:)
|
|
34
|
-
{ json: { errors: convert_errors(errors, "404") }, status: :not_found }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def Forbidden(errors:)
|
|
38
|
-
{ json: { errors: convert_errors(errors, "403") }, status: :forbidden }
|
|
39
|
-
end
|
|
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 }
|
|
29
|
+
def error_response(errors:, status:)
|
|
30
|
+
code = ActionFigure.status_code_for(status).to_s
|
|
31
|
+
{ json: { errors: convert_errors(errors, code) }, status: status }
|
|
47
32
|
end
|
|
48
33
|
|
|
49
34
|
private
|