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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2f1def52c6601cb91ff3b2cd2fc366efeef932c1fba3072932f674cbf1748af
4
- data.tar.gz: da5c9b0ab274f7e6f1512560af168d4d44c21dc3875ac85c0f79998902fa5df1
3
+ metadata.gz: d095409c73b40d8b3714b1e469e8edda371084844a277b486e5e06012453a301
4
+ data.tar.gz: 73a00b1d8003f3c17def805fa4e4cfabcf50ad52bf95f7df8fffa70e8ffdcefa
5
5
  SHA512:
6
- metadata.gz: 1ebdc99c40beb7e461eab4022825efd657f15c29fc01a84d432f253928040ad1191057ed7c6b5845670d34af2963e56c9760aec613b590e577d81bae60da2233
7
- data.tar.gz: d6b1112c59d61637c9e45e8f84b94ef25b75372e899b7f86e88ee0fc66d932325f567913ed75b47eb8283bde0a9d4e67935cbeac751665010b73cd56f1946cb9
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) | Four built-in formats: Default, JSend, JSON:API, and Wrapped. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. |
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) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
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: "users", id: "1", attributes: user } }, status: :created }
195
+ # => { json: { data: { type: "user", id: "1", attributes: user } }, status: :created }
195
196
 
196
197
  # Wrapped
197
198
  Created(resource: user)
@@ -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
- - A contract enforced at registration time: your module **must** define all 8 required methods.
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
- | `UnprocessableContent` | Validation or schema rule failure |
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
- Each method receives keyword arguments and must return a hash. The exact keywords depend on the outcome -- success methods receive `resource:` (and optionally `meta:`), while failure methods receive `errors:`.
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 UnprocessableContent(errors:)
60
- { json: { data: nil, errors: errors, status: "error" }, status: :unprocessable_content }
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 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.
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, :UnprocessableContent, :NotFound, :Forbidden, :Conflict, :PaymentRequired]
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 current user from the controller and call the authorization check during orchestration.
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
- Call the [Pundit](https://github.com/varvet/pundit) policy directly inside the action:
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 Users::DestroyAction
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
- user = User.find(params[:id])
207
- unless UserPolicy.new(current_user, user).destroy?
208
- return Forbidden(errors: { base: ["not authorized to delete this user"] })
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
- user.destroy!
210
+ project.destroy!
211
211
  NoContent()
212
212
  end
213
213
  end
214
214
  ```
215
215
 
216
216
  ```ruby
217
- class UsersController < ApplicationController
217
+ class ProjectsController < ApplicationController
218
218
  def destroy
219
- render Users::DestroyAction.call(params:, current_user: current_user)
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), build the ability from the current user:
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 Users::DestroyAction
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:, current_user:)
237
- user = User.find(params[:id])
238
- if Ability.new(current_user).can?(:destroy, user)
239
- user.destroy!
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 user"] })
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 UsersController < ApplicationController
251
+ class ProjectsController < ApplicationController
250
252
  def destroy
251
- render Users::DestroyAction.call(params:, current_user: current_user)
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.