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.
@@ -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 four built-in formatters: Default, JSend, JSON:API, and Wrapped.
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 nine response helpers. Eight return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`.
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 is the top-level JSON 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`.
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 becomes the entire JSON body with no wrapper.
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
- "id": 1,
89
- "name": "Jane Doe",
90
- "email": "jane@example.com"
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
- "id": 42,
109
- "name": "Jane Doe",
110
- "email": "jane@example.com"
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, the response wraps the resource under a `"data"` key so that `"meta"` can sit alongside it:
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
- "order_id": 7,
166
- "status": "processing"
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 four formatters. When `meta:` is `nil` (the default), the key is omitted entirely from the response. In the default formatter, providing `meta:` wraps the response in `{ "data": resource, "meta": meta }` — without `meta:`, the resource is the entire body.
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 | Gone | Domain | Action Class | The resource is permanently deleted (not just 404). |
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 | Locked | Domain | Action Class | The resource is being accessed by another process. |
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 | Unavailable For Legal Reasons | Domain | Action Class | Resource censored/blocked for legal/regional reasons. |
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
- # and to signal that your module implements the formatter interface.
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
- # Response helper names every formatter must define (+NoContent+ lives on +Formatter+, not required here).
9
- # Update every built-in formatter when you extend this list; +register_formatter+ validates against it at load time.
10
- REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired].freeze
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 UnprocessableContent(errors:)
29
- { json: { errors: errors }, status: :unprocessable_content }
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 UnprocessableContent(errors:)
29
- { json: { status: "fail", data: errors }, status: :unprocessable_content }
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 UnprocessableContent(errors:)
30
- { json: { errors: convert_errors(errors, "422") }, status: :unprocessable_content }
31
- end
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