action_figure 0.5.0 → 0.6.2

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.
@@ -47,7 +47,7 @@ end
47
47
 
48
48
  ## Response Helpers
49
49
 
50
- Every formatter implements the same seven response helpers. Six return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`.
50
+ Every formatter implements the same nine response helpers. Eight return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`.
51
51
 
52
52
  | Helper | HTTP Status | When to Use |
53
53
  |---------------------------------|--------------------------|--------------------------------------------------|
@@ -55,9 +55,11 @@ Every formatter implements the same seven response helpers. Six return a hash wi
55
55
  | `Created(resource:, meta: nil)` | `201 Created` | Successful resource creation |
56
56
  | `Accepted(resource: nil, meta: nil)` | `202 Accepted` | Request accepted for background processing |
57
57
  | `NoContent()` | `204 No Content` | Successful delete or action with no response body|
58
- | `UnprocessableContent(errors:)` | `422 Unprocessable Content` | Validation failures |
59
- | `NotFound(errors:)` | `404 Not Found` | Resource not found |
58
+ | `PaymentRequired(errors:)` | `402 Payment Required` | Business billing or quota constraint |
60
59
  | `Forbidden(errors:)` | `403 Forbidden` | Authorization failure |
60
+ | `NotFound(errors:)` | `404 Not Found` | Resource not found |
61
+ | `Conflict(errors:)` | `409 Conflict` | Resource state conflict or duplicate |
62
+ | `UnprocessableContent(errors:)` | `422 Unprocessable Content` | Validation failures |
61
63
 
62
64
  `NoContent` is shared across all formatters and is defined in the base `Formatter` module. It returns `{ status: :no_content }` with no JSON body.
63
65
 
@@ -227,6 +229,41 @@ end
227
229
  }
228
230
  ```
229
231
 
232
+ **`Conflict`:**
233
+
234
+ ```ruby
235
+ def call(params:)
236
+ return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
237
+ user = User.create(params)
238
+ Created(resource: user)
239
+ end
240
+ ```
241
+
242
+ ```json
243
+ {
244
+ "errors": {
245
+ "email": ["already registered"]
246
+ }
247
+ }
248
+ ```
249
+
250
+ **`PaymentRequired`:**
251
+
252
+ ```ruby
253
+ def call(params:, current_user:)
254
+ return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
255
+ Ok(resource: Dashboard.for(current_user))
256
+ end
257
+ ```
258
+
259
+ ```json
260
+ {
261
+ "errors": {
262
+ "base": ["subscription expired"]
263
+ }
264
+ }
265
+ ```
266
+
230
267
  ## JSend Format
231
268
 
232
269
  The JSend formatter wraps responses in the [JSend specification](https://github.com/omniti-labs/jsend) envelope.
@@ -382,6 +419,43 @@ end
382
419
  }
383
420
  ```
384
421
 
422
+ **`Conflict`:**
423
+
424
+ ```ruby
425
+ def call(params:)
426
+ return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
427
+ user = User.create(params)
428
+ Created(resource: user)
429
+ end
430
+ ```
431
+
432
+ ```json
433
+ {
434
+ "status": "fail",
435
+ "data": {
436
+ "email": ["already registered"]
437
+ }
438
+ }
439
+ ```
440
+
441
+ **`PaymentRequired`:**
442
+
443
+ ```ruby
444
+ def call(params:, current_user:)
445
+ return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
446
+ Ok(resource: Dashboard.for(current_user))
447
+ end
448
+ ```
449
+
450
+ ```json
451
+ {
452
+ "status": "fail",
453
+ "data": {
454
+ "base": ["subscription expired"]
455
+ }
456
+ }
457
+ ```
458
+
385
459
  ## Wrapped Format
386
460
 
387
461
  The Wrapped formatter places every response in a uniform `{ data:, errors:, status: }` envelope. Success responses use `"status": "success"` and failure responses use `"status": "error"`.
@@ -543,6 +617,45 @@ end
543
617
  }
544
618
  ```
545
619
 
620
+ **`Conflict`:**
621
+
622
+ ```ruby
623
+ def call(params:)
624
+ return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
625
+ user = User.create(params)
626
+ Created(resource: user)
627
+ end
628
+ ```
629
+
630
+ ```json
631
+ {
632
+ "data": null,
633
+ "errors": {
634
+ "email": ["already registered"]
635
+ },
636
+ "status": "error"
637
+ }
638
+ ```
639
+
640
+ **`PaymentRequired`:**
641
+
642
+ ```ruby
643
+ def call(params:, current_user:)
644
+ return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
645
+ Ok(resource: Dashboard.for(current_user))
646
+ end
647
+ ```
648
+
649
+ ```json
650
+ {
651
+ "data": null,
652
+ "errors": {
653
+ "base": ["subscription expired"]
654
+ },
655
+ "status": "error"
656
+ }
657
+ ```
658
+
546
659
  ## JSON:API Format
547
660
 
548
661
  The JSON:API formatter structures responses according to the [JSON:API specification](https://jsonapi.org/).
@@ -739,6 +852,49 @@ end
739
852
  }
740
853
  ```
741
854
 
855
+ **`Conflict`:**
856
+
857
+ ```ruby
858
+ def call(params:)
859
+ return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
860
+ user = User.create(params)
861
+ Created(resource: user)
862
+ end
863
+ ```
864
+
865
+ ```json
866
+ {
867
+ "errors": [
868
+ {
869
+ "status": "409",
870
+ "detail": "already registered",
871
+ "source": { "pointer": "/data/attributes/email" }
872
+ }
873
+ ]
874
+ }
875
+ ```
876
+
877
+ **`PaymentRequired`:**
878
+
879
+ ```ruby
880
+ def call(params:, current_user:)
881
+ return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
882
+ Ok(resource: Dashboard.for(current_user))
883
+ end
884
+ ```
885
+
886
+ ```json
887
+ {
888
+ "errors": [
889
+ {
890
+ "status": "402",
891
+ "detail": "subscription expired",
892
+ "source": { "pointer": "/data" }
893
+ }
894
+ ]
895
+ }
896
+ ```
897
+
742
898
  ## ActiveRecord Serialization (JSON:API)
743
899
 
744
900
  The JSON:API formatter includes automatic serialization for ActiveRecord objects via the `Resource` class.
@@ -0,0 +1,35 @@
1
+ # HTTP 4xx Status Codes
2
+
3
+ Rows in **bold** are status codes with built-in formatter methods — action classes can return these directly via helpers like `NotFound(errors:)` or `Conflict(errors:)`. All other 4xx codes are handled outside action classes by the perimeter (middleware, router, Rack, or infrastructure).
4
+
5
+ | Status | Name | Category | Responsibility | Description / Logic Example |
6
+ |--------|----------------------------------|-----------|-----------------|----------------------------------------------------------------------|
7
+ | 400 | Bad Request | Perimeter | Controller/Rack | Malformed syntax or missing top-level structure. |
8
+ | 401 | Unauthorized | Perimeter | Middleware/Auth | Authentication failed or missing credentials. |
9
+ | **402** | **Payment Required** | **Domain** | **Action Class** | **Business state: "Subscription overdue" or "Quota exceeded."** |
10
+ | **403** | **Forbidden** | **Domain** | **Action Class** | **Authenticated, but lacks permissions for this specific task.** |
11
+ | **404** | **Not Found** | **Domain** | **Action Class** | **The requested resource ID does not exist in the database.** |
12
+ | 405 | Method Not Allowed | Perimeter | Rails Router | Sending a POST to a GET route. |
13
+ | 406 | Not Acceptable | Perimeter | Controller | Client requested a format (e.g., XML) the server won't provide. |
14
+ | 407 | Proxy Auth Required | Perimeter | Infrastructure | Similar to 401, but for a proxy server. |
15
+ | 408 | Request Timeout | Perimeter | Server/Nginx | The client took too long to send the request. |
16
+ | **409** | **Conflict** | **Domain** | **Action Class** | **Resource already exists, or the state is in conflict.** |
17
+ | 410 | Gone | Domain | Action Class | The resource is permanently deleted (not just 404). |
18
+ | 411 | Length Required | Perimeter | Server/Rack | The request didn't specify a Content-Length. |
19
+ | 412 | Precondition Failed | Perimeter | Controller/Rack | If-Match headers don't match (usually for caching). |
20
+ | 413 | Payload Too Large | Perimeter | Server/Nginx | The request body is bigger than the server allows. |
21
+ | 414 | URI Too Long | Perimeter | Server/Nginx | The URL is too long for the server to process. |
22
+ | 415 | Unsupported Media Type | Perimeter | Controller | Sending text/plain instead of application/json. |
23
+ | 416 | Range Not Satisfiable | Perimeter | Server/Rack | Invalid Range header (usually for file downloads). |
24
+ | 417 | Expectation Failed | Perimeter | Server | The server can't meet the Expect header requirements. |
25
+ | 418 | I'm a teapot | Domain | Action Class | An IETF April Fools joke (rarely used in production). |
26
+ | 421 | Misdirected Request | Perimeter | Infrastructure | The server can't produce a response for this connection. |
27
+ | **422** | **Unprocessable Content** | **Domain** | **Action Class** | **Semantic errors (validation, business rules).** |
28
+ | 423 | Locked | Domain | Action Class | The resource is being accessed by another process. |
29
+ | 424 | Failed Dependency | Domain | Action Class | The request failed due to a failure of a previous request. |
30
+ | 425 | Too Early | Perimeter | Server/Rack | The server is unwilling to process a request that might be replayed. |
31
+ | 426 | Upgrade Required | Perimeter | Server/Rack | The client must switch to a different protocol (e.g., TLS). |
32
+ | 428 | Precondition Required | Perimeter | Controller/Rack | The server requires the request to be conditional. |
33
+ | 429 | Too Many Requests | Perimeter | Rack::Attack | Infrastructure-level rate limiting (IP-based, etc.). |
34
+ | 431 | Request Header Fields Too Large | Perimeter | Server/Rack | HTTP headers are too large. |
35
+ | 451 | Unavailable For Legal Reasons | Domain | Action Class | Resource censored/blocked for legal/regional reasons. |
data/docs/testing.md CHANGED
@@ -33,6 +33,10 @@ end
33
33
  | `assert_UnprocessableContent(result)` | `:unprocessable_content` |
34
34
  | `assert_NotFound(result)` | `:not_found` |
35
35
  | `assert_Forbidden(result)` | `:forbidden` |
36
+ | `assert_Conflict(result)` | `:conflict` |
37
+ | `assert_PaymentRequired(result)` | `:payment_required` |
38
+
39
+ 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.
36
40
 
37
41
  All assertions accept an optional second argument for a custom failure message:
38
42
 
@@ -59,6 +63,8 @@ Require the helper in your spec support file. No `include` is needed -- the matc
59
63
  require "action_figure/testing/rspec"
60
64
  ```
61
65
 
66
+ **Load order:** require this library **after** RSpec Core and expectations load (usual practice: append it toward the **bottom** of `spec/spec_helper.rb`, after any `require "rails_helper"` / `RSpec.configure` boilerplate from your app). ActionFigure pulls in **`rspec/matchers`**; minimalist scripts without the full **`rspec` CLI shim** must **`require "rspec/expectations"`** (and typically **`require "rspec/core"`**) *before* this file.
67
+
62
68
  ### Matchers
63
69
 
64
70
  | Matcher | Expected status |
@@ -70,6 +76,20 @@ require "action_figure/testing/rspec"
70
76
  | `be_UnprocessableContent` | `:unprocessable_content` |
71
77
  | `be_NotFound` | `:not_found` |
72
78
  | `be_Forbidden` | `:forbidden` |
79
+ | `be_Conflict` | `:conflict` |
80
+ | `be_PaymentRequired` | `:payment_required` |
81
+ | `have_action_json` | `result[:json]` matches `a_hash_including(fragment)` |
82
+
83
+ 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
+
85
+ ```ruby
86
+ expect(result).to be_Ok
87
+ expect(result).to have_action_json(status: "success")
88
+ expect(result).to have_action_json(
89
+ status: "success",
90
+ data: a_hash_including(name: "Jane")
91
+ )
92
+ ```
73
93
 
74
94
  Matchers support negation:
75
95
 
@@ -101,7 +121,7 @@ class Users::CreateActionTest < Minitest::Test
101
121
  include ActionFigure::Testing::Minitest
102
122
 
103
123
  def test_creates_a_user
104
- result = Users::CreateAction.call(params: { email: "jane@example.com", name: "Jane" })
124
+ result = Users::CreateAction.create(params: { email: "jane@example.com", name: "Jane" })
105
125
 
106
126
  assert_Ok(result)
107
127
  assert_equal "jane@example.com", result[:json][:data][:email]
@@ -119,7 +139,7 @@ class Users::CreateActionTest < Minitest::Test
119
139
  include ActionFigure::Testing::Minitest
120
140
 
121
141
  def test_rejects_missing_email
122
- result = Users::CreateAction.call(params: { name: "Jane" })
142
+ result = Users::CreateAction.create(params: { name: "Jane" })
123
143
 
124
144
  assert_UnprocessableContent(result)
125
145
  assert_includes result[:json][:data][:email], "is missing"
@@ -137,7 +157,7 @@ class Posts::CreateActionTest < Minitest::Test
137
157
 
138
158
  def test_creates_a_post_for_the_current_user
139
159
  user = users(:jane)
140
- result = Posts::CreateAction.call(params: { title: "Hello", body: "World" }, current_user: user)
160
+ result = Posts::CreateAction.create(params: { title: "Hello", body: "World" }, current_user: user)
141
161
 
142
162
  assert_Created(result)
143
163
  assert_equal user.id, result[:json][:data][:author_id]
@@ -145,9 +165,9 @@ class Posts::CreateActionTest < Minitest::Test
145
165
  end
146
166
  ```
147
167
 
148
- ### Testing a Custom Entry Point
168
+ ### Testing an Action with a Named Method
149
169
 
150
- When an action defines a custom class method (e.g., `.search`) instead of the default `.call`, call it by that name:
170
+ Call the action using its discovered method name:
151
171
 
152
172
  ```ruby
153
173
  class Products::SearchActionTest < Minitest::Test
@@ -156,8 +176,6 @@ class Products::SearchActionTest < Minitest::Test
156
176
  # class SearchAction
157
177
  # include ActionFigure[:jsend]
158
178
  #
159
- # entry_point :search
160
- #
161
179
  # params_schema do
162
180
  # required(:query).filled(:string)
163
181
  # end
@@ -187,14 +205,14 @@ class Sessions::DestroyActionTest < Minitest::Test
187
205
  # class Sessions::DestroyAction
188
206
  # include ActionFigure[:jsend]
189
207
  #
190
- # def call(session:)
208
+ # def destroy(session:)
191
209
  # session.destroy!
192
210
  # NoContent()
193
211
  # end
194
212
  # end
195
213
  def test_destroys_the_session
196
214
  session = sessions(:active)
197
- result = Sessions::DestroyAction.call(session: session)
215
+ result = Sessions::DestroyAction.destroy(session: session)
198
216
 
199
217
  assert_NoContent(result)
200
218
  end
@@ -224,7 +242,7 @@ result.failure? # => true
224
242
  result.errors.to_h # => { email: ["must be filled"] }
225
243
  ```
226
244
 
227
- This runs both the schema and any `rules` defined on the action -- the same validation pipeline that `.call` uses, without the side effects.
245
+ This runs both the schema and any `rules` defined on the action -- the same validation pipeline that the class-level trigger uses, without the side effects.
228
246
 
229
247
  Actions that do not define a `params_schema` return `nil` from `.contract`.
230
248
 
data/docs/validation.md CHANGED
@@ -9,7 +9,7 @@ The two layers are:
9
9
  1. **`params_schema`** -- structural validation and type coercion (powered by dry-schema)
10
10
  2. **`rules`** -- validation rules that run only after the schema passes
11
11
 
12
- If `params:` is not passed to the action at all, validation is skipped entirely and `#call` is invoked directly. If `params:` is passed but no `params_schema` is defined, an `ArgumentError` is raised immediately.
12
+ If no `params_schema` is defined, `params:` passes through to your `#call` method as-is — no validation, no coercion, no stripping of extra keys. This lets you rely on upstream validation (e.g., Rack middleware like `committee`) while still using ActionFigure for orchestration and response formatting.
13
13
 
14
14
  ---
15
15
 
@@ -17,6 +17,8 @@ If `params:` is not passed to the action at all, validation is skipped entirely
17
17
 
18
18
  `params_schema` accepts a block written in the [dry-schema](https://dry-rb.org/gems/dry-schema/) DSL. It defines the shape of your input: which keys are allowed, which are required, and what types they must be.
19
19
 
20
+ Each action calls **`params_schema` at most once** — a second call raises **`ArgumentError`**.
21
+
20
22
  ```ruby
21
23
  class Users::CreateAction
22
24
  include ActionFigure[:jsend]
@@ -100,6 +102,8 @@ end
100
102
  ArgumentError: rules requires params_schema to be defined
101
103
  ```
102
104
 
105
+ - `params_schema` **may only be called once per action class**. Calling it again raises **`ArgumentError`**, so your schema cannot be replaced in a way that could silently confuse or strand an existing **`rules`** block.
106
+
103
107
  - Inside a rule block, access validated values with `values[:field]`.
104
108
  - Add an error to a specific field with `key(:field).failure("message")`, or `key.failure("message")` when the rule is scoped to a single field via `rule(:field)`.
105
109
  - Multiple rules can target the same field. All rules run even if earlier ones fail -- errors accumulate.
@@ -36,13 +36,18 @@ module ActionFigure
36
36
  end
37
37
  end
38
38
 
39
- # DSL class methods extended into action classes: params_schema, rules, entry_point, call.
39
+ # DSL class methods extended into action classes: params_schema, rules, entry_point.
40
40
  #
41
41
  # Note: ActionFigure does not support class inheritance. +params_schema+, +rules+, and
42
42
  # +entry_point+ store state in class-level instance variables that are not inherited by
43
43
  # subclasses. Define each action class independently.
44
44
  module ClassMethods
45
45
  def params_schema(&block)
46
+ if @params_schema_block
47
+ raise ArgumentError,
48
+ "params_schema already defined — each action class may declare only one schema"
49
+ end
50
+
46
51
  @params_schema_block = block
47
52
  @contract = nil
48
53
  end
@@ -64,6 +69,7 @@ module ActionFigure
64
69
  "each action class may declare only one entry point"
65
70
  end
66
71
 
72
+ @explicit_entry_point = true
67
73
  @entry_point_name = name
68
74
  singleton_class.define_method(name) do |**kwargs|
69
75
  notify { new.validated_call(**kwargs) }
@@ -78,14 +84,6 @@ module ActionFigure
78
84
  value == :_unset ? @api_version : (@api_version = value)
79
85
  end
80
86
 
81
- def call(**)
82
- if @entry_point_name
83
- raise NoMethodError, "undefined method 'call' for #{self} (use '#{@entry_point_name}' instead)"
84
- end
85
-
86
- notify { new.validated_call(**) }
87
- end
88
-
89
87
  def contract
90
88
  return nil unless @params_schema_block
91
89
 
@@ -99,6 +97,38 @@ module ActionFigure
99
97
  yield
100
98
  end
101
99
 
100
+ def method_added(name)
101
+ disallow_action_initialize(name)
102
+
103
+ return if @explicit_entry_point || !public_method_defined?(name)
104
+ return unless instance_method(name).owner == self
105
+
106
+ if @entry_point_name
107
+ raise IndeterminateEntryPointError,
108
+ "Multiple public methods defined in #{self}: " \
109
+ ":#{@entry_point_name} and :#{name}. " \
110
+ "Either make one private or declare " \
111
+ "`entry_point :#{@entry_point_name}` to disambiguate."
112
+ end
113
+
114
+ @entry_point_name = name
115
+ singleton_class.define_method(name) do |**kwargs|
116
+ notify { new.validated_call(**kwargs) }
117
+ end
118
+ ensure
119
+ super
120
+ end
121
+
122
+ def disallow_action_initialize(method_name)
123
+ return unless method_name == :initialize
124
+ return unless instance_method(:initialize).owner == self
125
+
126
+ raise InitializationNotSupportedError,
127
+ "#{self} must not define initialize — ActionFigure invokes new with no " \
128
+ "arguments. Pass dependencies via the entry method's keyword arguments " \
129
+ "or use class-level collaborators."
130
+ end
131
+
102
132
  def build_contract
103
133
  schema_block = @params_schema_block
104
134
  rules_block = @rules_block
@@ -115,7 +145,7 @@ module ActionFigure
115
145
  end
116
146
 
117
147
  def entry_point_name
118
- self.class.entry_point_name || :call
148
+ self.class.entry_point_name
119
149
  end
120
150
 
121
151
  def contract
@@ -123,12 +153,12 @@ module ActionFigure
123
153
  end
124
154
 
125
155
  def validated_call(**kwargs)
126
- raise ArgumentError, "params: passed but no params_schema defined" if kwargs.key?(:params) && !contract
156
+ kwargs = normalize_params(kwargs)
127
157
 
128
- if kwargs.key?(:params)
129
- call_with_params(**kwargs)
158
+ if contract && kwargs.key?(:params)
159
+ validate_and_call(**kwargs)
130
160
  else
131
- call_without_params(**kwargs)
161
+ public_send(entry_point_name, **kwargs)
132
162
  end
133
163
  end
134
164
 
@@ -138,7 +168,10 @@ module ActionFigure
138
168
  private
139
169
 
140
170
  def notify
141
- payload = { action: name }
171
+ payload = {
172
+ action: name,
173
+ entry_point: entry_point_name
174
+ }
142
175
  ActiveSupport::Notifications.instrument("process.action_figure", payload) do
143
176
  result = yield
144
177
  payload[:status] = result[:status]
@@ -149,32 +182,54 @@ module ActionFigure
149
182
 
150
183
  private
151
184
 
152
- def call_with_params(**kwargs)
153
- raw_params = kwargs[:params]
154
- raw_params = raw_params.to_unsafe_h if raw_params.respond_to?(:to_unsafe_h)
185
+ def normalize_params(kwargs)
186
+ return kwargs unless contract
187
+
188
+ raw = kwargs[:params]
189
+ return kwargs unless raw.respond_to?(:to_unsafe_h)
190
+
191
+ kwargs.merge(params: raw.to_unsafe_h)
192
+ end
155
193
 
156
- result = contract.call(raw_params)
194
+ def validate_and_call(**kwargs)
195
+ result = contract.call(kwargs[:params])
157
196
 
158
197
  return UnprocessableContent(errors: result.errors.to_h) if result.failure?
159
198
 
160
- extra_params_error = check_extra_params(raw_params, result)
199
+ extra_params_error = check_extra_params(kwargs[:params], result)
161
200
  return extra_params_error if extra_params_error
162
201
 
163
- public_send(entry_point_name, **kwargs, params: result.to_h)
202
+ public_send(entry_point_name, **kwargs.except(:params), params: result.to_h)
164
203
  end
165
204
 
166
205
  def check_extra_params(raw_params, result)
167
206
  return unless ActionFigure.configuration.whiny_extra_params
168
207
 
169
- extra_keys = raw_params.keys.map(&:to_sym) - result.to_h.keys
170
- return if extra_keys.empty?
208
+ errors = find_extra_keys(raw_params, result.to_h)
209
+ return if errors.empty?
171
210
 
172
- errors = extra_keys.to_h { |k| [k, ["is not allowed"]] }
173
211
  UnprocessableContent(errors: errors)
174
212
  end
175
213
 
176
- def call_without_params(**)
177
- public_send(entry_point_name, **)
214
+ def find_extra_keys(raw, validated, prefix = nil)
215
+ top_level = extra_keys_at_level(raw, validated, prefix)
216
+ nested = nested_extra_keys(raw, validated, prefix)
217
+ top_level.merge(nested)
218
+ end
219
+
220
+ def extra_keys_at_level(raw, validated, prefix)
221
+ (raw.keys.map(&:to_sym) - validated.keys).to_h do |k|
222
+ [prefix ? :"#{prefix}.#{k}" : k, ["is not allowed"]]
223
+ end
224
+ end
225
+
226
+ def nested_extra_keys(raw, validated, prefix)
227
+ validated.each_with_object({}) do |(key, value), errors|
228
+ next unless value.is_a?(Hash) && raw[key].is_a?(Hash)
229
+
230
+ nested_prefix = [prefix, key].compact.join(".")
231
+ errors.merge!(find_extra_keys(raw[key], value, nested_prefix))
232
+ end
178
233
  end
179
234
  end
180
235
  end
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/map"
4
+
3
5
  module ActionFigure
4
6
  # Provides formatter registration and lookup for ActionFigure.
5
7
  module FormatRegistry
6
8
  # Stores the mapping of format names to formatter modules.
7
9
  class Formats
8
10
  def initialize
9
- @formats = {}
11
+ @formats = Concurrent::Map.new
10
12
  end
11
13
 
12
14
  def register_formatter(**formatters)
13
- @formats.merge!(formatters)
15
+ formatters.each { |name, mod| @formats[name] = mod }
14
16
  end
15
17
 
16
18
  def fetch(name)
@@ -5,7 +5,9 @@ module ActionFigure
5
5
  # Include this in your formatter module to get a NoContent default
6
6
  # and to signal that your module implements the formatter interface.
7
7
  module Formatter
8
- REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden].freeze
8
+ # 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
9
11
 
10
12
  def NoContent
11
13
  { status: :no_content }
@@ -3,23 +3,25 @@
3
3
  module ActionFigure
4
4
  module Formatters
5
5
  # Implements Rails-style response helpers for use in action classes.
6
- # Resource is the top-level JSON on success; errors live under an "errors" key on failure.
6
+ # Success responses use a { data: } envelope; errors live under an "errors" key on failure.
7
7
  module Default
8
8
  include ActionFigure::Formatter
9
9
 
10
10
  def Ok(resource:, meta: nil)
11
- body = meta ? { data: resource, meta: meta } : resource
11
+ body = { data: resource }
12
+ body[:meta] = meta if meta
12
13
  { json: body, status: :ok }
13
14
  end
14
15
 
15
16
  def Created(resource:, meta: nil)
16
- body = meta ? { data: resource, meta: meta } : resource
17
+ body = { data: resource }
18
+ body[:meta] = meta if meta
17
19
  { json: body, status: :created }
18
20
  end
19
21
 
20
22
  def Accepted(resource: nil, meta: nil)
21
- body = resource.nil? ? {} : resource
22
- body = { data: body, meta: meta } if meta
23
+ body = { data: resource }
24
+ body[:meta] = meta if meta
23
25
  { json: body, status: :accepted }
24
26
  end
25
27
 
@@ -34,6 +36,14 @@ module ActionFigure
34
36
  def Forbidden(errors:)
35
37
  { json: { errors: errors }, status: :forbidden }
36
38
  end
39
+
40
+ def Conflict(errors:)
41
+ { json: { errors: errors }, status: :conflict }
42
+ end
43
+
44
+ def PaymentRequired(errors:)
45
+ { json: { errors: errors }, status: :payment_required }
46
+ end
37
47
  end
38
48
  end
39
49
  end
@@ -36,6 +36,14 @@ module ActionFigure
36
36
  def Forbidden(errors:)
37
37
  { json: { status: "fail", data: errors }, status: :forbidden }
38
38
  end
39
+
40
+ def Conflict(errors:)
41
+ { json: { status: "fail", data: errors }, status: :conflict }
42
+ end
43
+
44
+ def PaymentRequired(errors:)
45
+ { json: { status: "fail", data: errors }, status: :payment_required }
46
+ end
39
47
  end
40
48
  end
41
49
  end