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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d095409c73b40d8b3714b1e469e8edda371084844a277b486e5e06012453a301
|
|
4
|
+
data.tar.gz: 73a00b1d8003f3c17def805fa4e4cfabcf50ad52bf95f7df8fffa70e8ffdcefa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ed2a8b934e988503dcf41c07c3d39f3418e8b4c9e28f1ccbdb9fc7c8b709d7efa2d8141294f8cd8da8323f4b9f22d86f346fdc40010eb0d59e0872e476bf888
|
|
7
|
+
data.tar.gz: 65f498c906171ecbb98183700c0ae2c802fa1bbe3e09beb2ca69bb076eb308bd27f3999312e49fe10d18a5821383629f507be27a54f7f0451b9ab3ed2b27cded
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to ActionFigure will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.7.0] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Central error-status registry.** `ActionFigure.error_statuses` lists every error helper (name → Rack status symbol); `ActionFigure.register_error(:BadGateway, :bad_gateway)` adds new ones. Generated helpers live on a registry module included into `ActionFigure::Formatter`, so a registered status is immediately available in **every** formatter, in action classes that were composed **before** the registration, and as `assert_*`/`refute_*`/`be_*` test helpers.
|
|
10
|
+
- New built-in error statuses: **`Gone`** (410), **`Locked`** (423), and **`UnavailableForLegalReasons`** (451).
|
|
11
|
+
- `ActionFigure.status_code_for(status_symbol)` — resolves a Rack status symbol to its numeric code (accepts both `:unprocessable_content` and `:unprocessable_entity` on every supported Rack version).
|
|
12
|
+
- `register_error` validates its status symbol against Rack's status table and rejects helper names reserved by the formatter contract, so typos fail at boot instead of on the first rendered error.
|
|
13
|
+
- `:rfc_9457` formatter implementing RFC 9457 Problem Details for HTTP APIs.
|
|
14
|
+
Errors render as `application/problem+json` with `type`, `title`, `status`,
|
|
15
|
+
`detail`, `instance`, and extension members. Success responses use the same
|
|
16
|
+
vocabulary (`type`, `title`, named resource key). Defaults derive from class
|
|
17
|
+
names and status symbols; all members accept override kwargs.
|
|
18
|
+
- Generated error helpers now accept `errors: nil` (previously required) and
|
|
19
|
+
forward `**extras` to `error_response`. Existing formatters are unaffected
|
|
20
|
+
when called without extras.
|
|
21
|
+
- **Contract assertions** — test an action's **`params_schema`**/**`rules`** in isolation, without invoking the action body. Minitest: **`assert_valid_params(action_class, params)`** and **`assert_invalid_params(action_class, params, on: :field)`**. RSpec: **`accept_params(params)`** and **`reject_params(params).with_error_on(:field)`** (subject is the action class). These are formatter-agnostic. Both raise a clear **`ArgumentError`** for actions without a **`params_schema`**.
|
|
22
|
+
- **`assert_action_json`** / **`refute_action_json`** Minitest assertions — partial match on **`result[:json]`**, mirroring the RSpec **`have_action_json`** matcher. Nested Hashes match as subsets and **`Regexp`** values match against strings.
|
|
23
|
+
- Negated Minitest status assertions: **`refute_Ok`**, **`refute_Created`**, … for every status (parity with RSpec **`not_to be_Ok`**).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Formatter contract shrank from 8 methods to 4**: `Ok`, `Created`, `Accepted`, and the new `error_response(errors:, status:)`. Named error helpers (`NotFound`, `Conflict`, …) are generated from the registry and delegate to `error_response`; a hand-defined named helper on a formatter still wins.
|
|
28
|
+
- The gem now declares a runtime dependency on **rack** (>= 2.2), used to resolve and validate status codes.
|
|
29
|
+
- Status helpers for both adapters are now generated from the live registry (**`ActionFigure::Testing.statuses`**, including **`NoContent`**), so the Minitest and RSpec lists can no longer drift and newly registered statuses appear automatically. Replaces the RSpec-only **`ActionFigure::Testing::RSpec::MATCHERS`** constant.
|
|
30
|
+
- Status assertions/matchers now fail with a clear "expected an ActionFigure result hash" message when given a non-Hash, instead of raising **`NoMethodError`**.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
|
|
34
|
+
- The deprecated `IndeterminantEntryPointError` alias (misspelled constant shipped through 0.6.0, aliased in 0.6.1). Use **`IndeterminateEntryPointError`**; update any remaining `rescue` clauses.
|
|
35
|
+
|
|
36
|
+
### Known limitation
|
|
37
|
+
|
|
38
|
+
- There is **no** formatter-agnostic assertion for **non-validation** error bodies (e.g. **`NotFound`**/**`Conflict`** with a custom **`errors:`** payload). Each formatter stores errors differently (**`json[:errors]`** vs **`json[:data]`** vs a JSON:API array) and the result hash carries no formatter identity. Assert those with a format-specific **`assert_action_json`** / **`have_action_json`**. Validation errors are best tested via the contract assertions above.
|
|
39
|
+
|
|
5
40
|
## [0.6.2] - 2026-06-25
|
|
6
41
|
|
|
7
42
|
### Fixed
|
data/README.md
CHANGED
|
@@ -130,13 +130,14 @@ Every action class has three responsibilities:
|
|
|
130
130
|
| Feature | Description |
|
|
131
131
|
|---------|-------------|
|
|
132
132
|
| [Validation](docs/validation.md) | Two-layer validation powered by dry-validation: structural schemas with type coercion, plus validation rules. Includes cross-parameter helpers like `exclusive_rule`, `any_rule`, `one_rule`, and `all_rule`. |
|
|
133
|
-
| [Response Formatters](docs/response-formatters.md) |
|
|
133
|
+
| [Response Formatters](docs/response-formatters.md) | Five built-in formats: Default, JSend, JSON:API, Wrapped, and RFC 9457. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. |
|
|
134
|
+
| [Problem Details](docs/problem-details.md) | RFC 9457 formatter (`:rfc_9457`) that renders errors as `application/problem+json` problem documents with `type`, `title`, `status`, `detail`, and `instance` members. Success responses mirror the same vocabulary. |
|
|
134
135
|
| [Status Codes](docs/status-codes.md) | Which 4xx codes are domain concerns (handled by action classes) vs perimeter concerns (handled by middleware, router, or infrastructure). |
|
|
135
136
|
| [Custom Formatters](docs/custom-formatters.md) | Define your own response envelope by implementing the formatter interface. Registration validates your module at load time. |
|
|
136
137
|
| [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. |
|
|
137
138
|
| [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
|
|
138
139
|
| [Notifications](docs/activesupport-notifications.md) | Opt-in `ActiveSupport::Notifications` events for every action call. Emits action class, outcome status, and duration on the `process.action_figure` event. |
|
|
139
|
-
| [Testing](docs/testing.md) |
|
|
140
|
+
| [Testing](docs/testing.md) | Status assertions/matchers (`assert_Ok`/`be_Ok`, ... plus negated `refute_Ok`), body matchers (`assert_action_json`/`have_action_json`), and formatter-agnostic contract assertions (`assert_valid_params`/`accept_params`, `assert_invalid_params`/`reject_params`). |
|
|
140
141
|
| [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
|
|
141
142
|
|
|
142
143
|
## Design Philosophy
|
|
@@ -191,7 +192,7 @@ Created(resource: user)
|
|
|
191
192
|
|
|
192
193
|
# JSON:API
|
|
193
194
|
Created(resource: user)
|
|
194
|
-
# => { json: { data: { type: "
|
|
195
|
+
# => { json: { data: { type: "user", id: "1", attributes: user } }, status: :created }
|
|
195
196
|
|
|
196
197
|
# Wrapped
|
|
197
198
|
Created(resource: user)
|
data/docs/custom-formatters.md
CHANGED
|
@@ -11,24 +11,23 @@ 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
|
-
-
|
|
14
|
+
- Named error helpers for every registered error status (`NotFound`, `Conflict`, `Gone`, etc.), carried by a live registry module included into `ActionFigure::Formatter`. Each generated helper delegates to `error_response`.
|
|
15
|
+
- A contract enforced at registration time: your module **must** define all 4 required methods.
|
|
15
16
|
|
|
16
17
|
The required methods are:
|
|
17
18
|
|
|
18
|
-
| Method | Purpose
|
|
19
|
-
|
|
20
|
-
| `Ok` | Successful retrieval or generic success
|
|
21
|
-
| `Created` | Resource was created
|
|
22
|
-
| `Accepted` | Request accepted for background processing
|
|
23
|
-
| `
|
|
24
|
-
| `NotFound` | Resource not found |
|
|
25
|
-
| `Forbidden` | Authorization failure |
|
|
26
|
-
| `Conflict` | Resource state conflict or duplicate |
|
|
27
|
-
| `PaymentRequired` | Business billing or quota constraint |
|
|
19
|
+
| Method | Purpose |
|
|
20
|
+
|------------------------|------------------------------------------------------------------|
|
|
21
|
+
| `Ok` | Successful retrieval or generic success |
|
|
22
|
+
| `Created` | Resource was created |
|
|
23
|
+
| `Accepted` | Request accepted for background processing |
|
|
24
|
+
| `error_response` | Low-level failure renderer called by every named error helper |
|
|
28
25
|
|
|
29
26
|
`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.
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
Named error helpers (`NotFound`, `Conflict`, `Gone`, `Locked`, `UnavailableForLegalReasons`, and any additional statuses you register) are generated from the error registry and all delegate to `error_response(errors:, status:, **extras)` — `errors:` is `nil` when the caller doesn't provide one, and any extra kwargs are forwarded. A formatter may still hand-define a specific named helper to override the generated one — a method defined on the formatter itself sits ahead of the generated helpers in the ancestor chain and wins.
|
|
29
|
+
|
|
30
|
+
`error_response` receives the keyword arguments `errors:` (an error hash, or `nil`) and `status:` (a Rack status symbol), plus any pass-through kwargs the caller supplied, and must return a hash. See the contract update note under [Interface Validation](#interface-validation) for the recommended signature.
|
|
32
31
|
|
|
33
32
|
## Building a Custom Formatter
|
|
34
33
|
|
|
@@ -56,29 +55,13 @@ module WrappedFormatter
|
|
|
56
55
|
{ json: body, status: :accepted }
|
|
57
56
|
end
|
|
58
57
|
|
|
59
|
-
def
|
|
60
|
-
{ json: { data: nil, errors: errors, status: "error" }, status:
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def NotFound(errors:)
|
|
64
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :not_found }
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def Forbidden(errors:)
|
|
68
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def Conflict(errors:)
|
|
72
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :conflict }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def PaymentRequired(errors:)
|
|
76
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :payment_required }
|
|
58
|
+
def error_response(errors:, status:)
|
|
59
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: status }
|
|
77
60
|
end
|
|
78
61
|
end
|
|
79
62
|
```
|
|
80
63
|
|
|
81
|
-
All
|
|
64
|
+
All 4 required methods are defined. Success methods accept `resource:` and optionally `meta:`. `error_response` accepts `errors:` and `status:` and is called by every generated named error helper. 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.
|
|
82
65
|
|
|
83
66
|
## Registering Your Formatter
|
|
84
67
|
|
|
@@ -115,9 +98,13 @@ Registration is not just bookkeeping -- ActionFigure validates every formatter m
|
|
|
115
98
|
|
|
116
99
|
```ruby
|
|
117
100
|
ActionFigure::Formatter::REQUIRED_METHODS
|
|
118
|
-
# => [:Ok, :Created, :Accepted, :
|
|
101
|
+
# => [:Ok, :Created, :Accepted, :error_response]
|
|
119
102
|
```
|
|
120
103
|
|
|
104
|
+
**Migration note (pre-0.7 formatters):** If you have a formatter written against the pre-0.7 eight-method contract that does not define `error_response`, registration will now **fail** — `REQUIRED_METHODS` requires `error_response`. Add an `error_response(errors:, status:)` method to your formatter before upgrading to 0.7.
|
|
105
|
+
|
|
106
|
+
**`error_response` contract update (0.7+):** Generated error helpers now call `error_response` with `errors: nil` (previously `errors:` was required and always provided). If your formatter declares `error_response(errors:, status:)` strictly, it will continue to work for calls that pass `errors:`. However, if your formatter wants to support pass-through kwargs such as `detail:`, `instance:`, or custom extension members (as the `:rfc_9457` formatter does), declare the signature as `error_response(errors: nil, status:, **extras)` and forward `**extras` in your implementation. A strict formatter that declares only `(errors:, status:)` will raise `ArgumentError` when a caller passes extra kwargs — this is documented-correct behavior, not a bug. Only add `**extras` if your formatter intends to handle or forward those members.
|
|
107
|
+
|
|
121
108
|
If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent:
|
|
122
109
|
|
|
123
110
|
```ruby
|
|
@@ -130,8 +117,7 @@ module IncompleteFormatter
|
|
|
130
117
|
end
|
|
131
118
|
|
|
132
119
|
ActionFigure.register_formatter(incomplete: IncompleteFormatter)
|
|
133
|
-
# => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted,
|
|
134
|
-
# UnprocessableContent, NotFound, Forbidden, Conflict, PaymentRequired
|
|
120
|
+
# => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted, error_response
|
|
135
121
|
```
|
|
136
122
|
|
|
137
123
|
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.
|
|
@@ -185,3 +171,17 @@ end
|
|
|
185
171
|
```
|
|
186
172
|
|
|
187
173
|
Setting `config.format` makes every action use your formatter unless an individual action explicitly includes a different one. Per-action includes always take precedence over the global default.
|
|
174
|
+
|
|
175
|
+
## Registering Additional Error Statuses
|
|
176
|
+
|
|
177
|
+
ActionFigure ships with eight built-in error statuses (see [HTTP 4xx Status Codes](status-codes.md)). You can register additional ones with `ActionFigure.register_error`:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
ActionFigure.register_error(:BadGateway, :bad_gateway)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The first argument is the helper name (a Symbol or String) that will be available in action classes (`BadGateway(errors:)`). The second argument must be a valid Rack status symbol — `register_error` validates it against `Rack::Utils::SYMBOL_TO_STATUS_CODE` and raises `ArgumentError` immediately for a symbol no supported Rack version knows, so a typo fails at boot rather than on the first error rendered in production.
|
|
184
|
+
|
|
185
|
+
Registration is **add-only**: you cannot remove or override a built-in status. Names reserved by the formatter contract (`Ok`, `Created`, `Accepted`, `NoContent`, `error_response`) are rejected.
|
|
186
|
+
|
|
187
|
+
Registration can happen at any time. Generated helpers live on a single registry module included into `ActionFigure::Formatter`, so a newly registered status is immediately available to every formatter and every action class — including classes that ran `include ActionFigure[...]` before the registration — and `register_error` also patches any already-loaded Minitest assertions and RSpec matchers. An initializer is still the natural home for registrations, but nothing breaks if one runs late.
|
|
@@ -188,14 +188,14 @@ end
|
|
|
188
188
|
|
|
189
189
|
## Authorization
|
|
190
190
|
|
|
191
|
-
Authorization gems work naturally with action classes. Inject the
|
|
191
|
+
Authorization gems work naturally with action classes. Inject the authorization context from the controller -- the current user for Pundit, the current ability for CanCanCan -- and run the check during orchestration.
|
|
192
192
|
|
|
193
193
|
### Pundit
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
Let [Pundit](https://github.com/varvet/pundit) infer the policy from the record with `Pundit.policy!`, then call the query. It returns a plain boolean, so a denial flows through your formatter as a `Forbidden` response -- no `rescue_from`, no exceptions:
|
|
196
196
|
|
|
197
197
|
```ruby
|
|
198
|
-
class
|
|
198
|
+
class Projects::DestroyAction
|
|
199
199
|
include ActionFigure[:jsend]
|
|
200
200
|
|
|
201
201
|
params_schema do
|
|
@@ -203,52 +203,54 @@ class Users::DestroyAction
|
|
|
203
203
|
end
|
|
204
204
|
|
|
205
205
|
def call(params:, current_user:)
|
|
206
|
-
|
|
207
|
-
unless
|
|
208
|
-
return Forbidden(errors: { base: ["not authorized to delete this
|
|
206
|
+
project = Project.find(params[:id])
|
|
207
|
+
unless Pundit.policy!(current_user, project).destroy?
|
|
208
|
+
return Forbidden(errors: { base: ["not authorized to delete this project"] })
|
|
209
209
|
end
|
|
210
|
-
|
|
210
|
+
project.destroy!
|
|
211
211
|
NoContent()
|
|
212
212
|
end
|
|
213
213
|
end
|
|
214
214
|
```
|
|
215
215
|
|
|
216
216
|
```ruby
|
|
217
|
-
class
|
|
217
|
+
class ProjectsController < ApplicationController
|
|
218
218
|
def destroy
|
|
219
|
-
render
|
|
219
|
+
render Projects::DestroyAction.call(params:, current_user: current_user)
|
|
220
220
|
end
|
|
221
221
|
end
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
+
`Pundit.policy!(current_user, project)` does the same policy-class lookup as a controller (`project` → `ProjectPolicy`); you name the query (`:destroy?`) explicitly, since an action class has no controller `action_name` to infer it from.
|
|
225
|
+
|
|
224
226
|
### CanCanCan
|
|
225
227
|
|
|
226
|
-
With [CanCanCan](https://github.com/CanCanCommunity/cancancan),
|
|
228
|
+
With [CanCanCan](https://github.com/CanCanCommunity/cancancan), inject the controller's `current_ability` and consult it directly. CanCanCan's `can?` and `authorize!` helpers already delegate to `current_ability`, so passing the ability keeps the action idiomatic and decoupled from how the ability is built:
|
|
227
229
|
|
|
228
230
|
```ruby
|
|
229
|
-
class
|
|
231
|
+
class Projects::DestroyAction
|
|
230
232
|
include ActionFigure[:jsend]
|
|
231
233
|
|
|
232
234
|
params_schema do
|
|
233
235
|
required(:id).filled(:integer)
|
|
234
236
|
end
|
|
235
237
|
|
|
236
|
-
def call(params:,
|
|
237
|
-
|
|
238
|
-
if
|
|
239
|
-
|
|
238
|
+
def call(params:, current_ability:)
|
|
239
|
+
project = Project.find(params[:id])
|
|
240
|
+
if current_ability.can?(:destroy, project)
|
|
241
|
+
project.destroy!
|
|
240
242
|
NoContent()
|
|
241
243
|
else
|
|
242
|
-
Forbidden(errors: { base: ["not authorized to delete this
|
|
244
|
+
Forbidden(errors: { base: ["not authorized to delete this project"] })
|
|
243
245
|
end
|
|
244
246
|
end
|
|
245
247
|
end
|
|
246
248
|
```
|
|
247
249
|
|
|
248
250
|
```ruby
|
|
249
|
-
class
|
|
251
|
+
class ProjectsController < ApplicationController
|
|
250
252
|
def destroy
|
|
251
|
-
render
|
|
253
|
+
render Projects::DestroyAction.call(params:, current_ability:)
|
|
252
254
|
end
|
|
253
255
|
end
|
|
254
256
|
```
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Problem Details (RFC 9457)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `:rfc_9457` formatter renders errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents with `Content-Type: application/problem+json`. Success responses mirror the same `type`/`title` vocabulary, though RFC 9457 itself only specifies error documents.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class Projects::FindAction
|
|
9
|
+
include ActionFigure[:rfc_9457]
|
|
10
|
+
|
|
11
|
+
def show(params:)
|
|
12
|
+
project = Project.find_by(id: params[:id])
|
|
13
|
+
return NotFound(
|
|
14
|
+
errors: { id: ["not found"] },
|
|
15
|
+
type: "https://api.example.com/problems/project-not-found",
|
|
16
|
+
title: "Project not found",
|
|
17
|
+
detail: "No project with id #{params[:id]} exists.",
|
|
18
|
+
instance: "/projects/#{params[:id]}"
|
|
19
|
+
) unless project
|
|
20
|
+
|
|
21
|
+
Ok(resource: project, type: "project-found", title: "Project found")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Error Responses
|
|
27
|
+
|
|
28
|
+
Error helpers (`NotFound`, `Conflict`, etc.) produce a problem document:
|
|
29
|
+
|
|
30
|
+
| Member | Default | Override kwarg |
|
|
31
|
+
|------------|------------------------------------------------------------|----------------|
|
|
32
|
+
| `type` | `"<action-class>-<status>-error"`; `UnprocessableContent` uses `"unprocessable-content-error"` (see Defaults below) | `type:` |
|
|
33
|
+
| `title` | HTTP status phrase, e.g. `"Not Found"` | `title:` |
|
|
34
|
+
| `status` | Numeric HTTP code, e.g. `404` | — |
|
|
35
|
+
| `detail` | Omitted | `detail:` |
|
|
36
|
+
| `instance` | Omitted | `instance:` |
|
|
37
|
+
| `errors` | Extension member; omitted when nil | `errors:` |
|
|
38
|
+
|
|
39
|
+
Any additional kwargs become extension members.
|
|
40
|
+
|
|
41
|
+
The render hash includes `content_type: "application/problem+json"`. Rails honors this directly; a plain Rack app can read the key.
|
|
42
|
+
|
|
43
|
+
### Defaults
|
|
44
|
+
|
|
45
|
+
`UnprocessableContent` has a fixed default type of `"unprocessable-content-error"`. The framework calls this helper automatically on schema validation failure, so a stable type is provided without configuration:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Schema failure — type is already "unprocessable-content-error":
|
|
49
|
+
UnprocessableContent(errors: result.errors.to_h)
|
|
50
|
+
|
|
51
|
+
# Override when you want a domain-specific URI:
|
|
52
|
+
UnprocessableContent(
|
|
53
|
+
errors: user.errors.messages,
|
|
54
|
+
type: "https://api.example.com/problems/validation-error",
|
|
55
|
+
title: "Validation failed"
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
All other error helpers derive `type` mechanically from the action class and HTTP status — `Projects::FindAction` + `NotFound` → `"projects-find-not-found-error"`. This is intentionally awkward. The RFC recommends a stable URI, and your API clients deserve one:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# Awkward default — a signal to replace it:
|
|
63
|
+
# type: "projects-find-not-found-error"
|
|
64
|
+
|
|
65
|
+
# What you should write instead:
|
|
66
|
+
NotFound(
|
|
67
|
+
type: "https://api.example.com/problems/project-not-found",
|
|
68
|
+
title: "Project not found",
|
|
69
|
+
errors: { id: ["not found"] }
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Success Responses
|
|
74
|
+
|
|
75
|
+
Success helpers (`Ok`, `Created`, `Accepted`) build a mirrored vocabulary:
|
|
76
|
+
|
|
77
|
+
| Member | Default | Override kwarg |
|
|
78
|
+
|-------------------|----------------------------------------------------|----------------|
|
|
79
|
+
| `type` | `"<resource-name>-<status>"` (see below) | `type:` |
|
|
80
|
+
| `title` | `"<Resource name> <status>"` (see below) | `title:` |
|
|
81
|
+
| `<resource-key>` | Resource under its class-derived name or `data` | `as:` |
|
|
82
|
+
| `meta` | Omitted when nil | `meta:` |
|
|
83
|
+
|
|
84
|
+
The resource key and name derive from the resource's class:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
Created(resource: user) # User instance → key :user
|
|
88
|
+
# type: "user-created", title: "User created", user: { ... }
|
|
89
|
+
|
|
90
|
+
Created(resource: some_hash) # Hash → key :data
|
|
91
|
+
# type: "resource-created", title: "Resource created", data: { ... }
|
|
92
|
+
|
|
93
|
+
Created(resource: h, as: :project) # explicit override
|
|
94
|
+
# type: "project-created", title: "Project created", project: { ... }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`as:`, `type:`, and `title:` can be used independently — `as:` drives the resource key and the derived defaults; `type:` and `title:` override only their own member.
|
|
98
|
+
|
|
99
|
+
Trailing `Action` is stripped from class names: `Projects::CreateAction` → `"projects-create"`.
|
|
100
|
+
|
|
101
|
+
`Ok(resource: user)` defaults to `type: "user-ok"` / `title: "User ok"` — deliberately awkward for the same reason as error defaults. Pass `type:` and `title:` to give your clients something meaningful.
|
|
102
|
+
|
|
103
|
+
Success responses use plain `application/json` (no `content_type:` key).
|
|
104
|
+
|
|
105
|
+
`NoContent` returns `{ status: :no_content }` with no body — inherited from the base formatter.
|
|
106
|
+
|
|
107
|
+
## Registering Additional Error Statuses
|
|
108
|
+
|
|
109
|
+
`ActionFigure.register_error` works the same way as with any other formatter. Registered error helpers automatically use `error_response`, so they produce problem documents in `:rfc_9457` action classes without any extra configuration.
|
|
110
|
+
|
|
111
|
+
## Custom `error_response` Contract
|
|
112
|
+
|
|
113
|
+
If you write a custom formatter that you want to compose with RFC 9457 style, note that `error_response` now receives `errors: nil, status:, **extras`. Accept `**extras` to let `detail:`, `instance:`, and extension members flow through.
|