action_figure 0.5.0 → 0.6.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.
@@ -11,7 +11,7 @@ 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 6 required methods.
14
+ - A contract enforced at registration time: your module **must** define all 8 required methods.
15
15
 
16
16
  The required methods are:
17
17
 
@@ -23,6 +23,8 @@ The required methods are:
23
23
  | `UnprocessableContent` | Validation or schema rule failure |
24
24
  | `NotFound` | Resource not found |
25
25
  | `Forbidden` | Authorization failure |
26
+ | `Conflict` | Resource state conflict or duplicate |
27
+ | `PaymentRequired` | Business billing or quota constraint |
26
28
 
27
29
  `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.
28
30
 
@@ -63,10 +65,18 @@ module WrappedFormatter
63
65
  def Forbidden(errors:)
64
66
  { json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
65
67
  end
68
+
69
+ def Conflict(errors:)
70
+ { json: { data: nil, errors: errors, status: "error" }, status: :conflict }
71
+ end
72
+
73
+ def PaymentRequired(errors:)
74
+ { json: { data: nil, errors: errors, status: "error" }, status: :payment_required }
75
+ end
66
76
  end
67
77
  ```
68
78
 
69
- All 6 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.
79
+ 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.
70
80
 
71
81
  ## Registering Your Formatter
72
82
 
@@ -103,7 +113,7 @@ Registration is not just bookkeeping -- ActionFigure validates every formatter m
103
113
 
104
114
  ```ruby
105
115
  ActionFigure::Formatter::REQUIRED_METHODS
106
- # => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden]
116
+ # => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden, :Conflict, :PaymentRequired]
107
117
  ```
108
118
 
109
119
  If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent:
@@ -119,7 +129,7 @@ end
119
129
 
120
130
  ActionFigure.register_formatter(incomplete: IncompleteFormatter)
121
131
  # => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted,
122
- # UnprocessableContent, NotFound, Forbidden
132
+ # UnprocessableContent, NotFound, Forbidden, Conflict, PaymentRequired
123
133
  ```
124
134
 
125
135
  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.
@@ -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,8 @@ 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` |
36
38
 
37
39
  All assertions accept an optional second argument for a custom failure message:
38
40
 
@@ -70,6 +72,8 @@ require "action_figure/testing/rspec"
70
72
  | `be_UnprocessableContent` | `:unprocessable_content` |
71
73
  | `be_NotFound` | `:not_found` |
72
74
  | `be_Forbidden` | `:forbidden` |
75
+ | `be_Conflict` | `:conflict` |
76
+ | `be_PaymentRequired` | `:payment_required` |
73
77
 
74
78
  Matchers support negation:
75
79
 
@@ -101,7 +105,7 @@ class Users::CreateActionTest < Minitest::Test
101
105
  include ActionFigure::Testing::Minitest
102
106
 
103
107
  def test_creates_a_user
104
- result = Users::CreateAction.call(params: { email: "jane@example.com", name: "Jane" })
108
+ result = Users::CreateAction.create(params: { email: "jane@example.com", name: "Jane" })
105
109
 
106
110
  assert_Ok(result)
107
111
  assert_equal "jane@example.com", result[:json][:data][:email]
@@ -119,7 +123,7 @@ class Users::CreateActionTest < Minitest::Test
119
123
  include ActionFigure::Testing::Minitest
120
124
 
121
125
  def test_rejects_missing_email
122
- result = Users::CreateAction.call(params: { name: "Jane" })
126
+ result = Users::CreateAction.create(params: { name: "Jane" })
123
127
 
124
128
  assert_UnprocessableContent(result)
125
129
  assert_includes result[:json][:data][:email], "is missing"
@@ -137,7 +141,7 @@ class Posts::CreateActionTest < Minitest::Test
137
141
 
138
142
  def test_creates_a_post_for_the_current_user
139
143
  user = users(:jane)
140
- result = Posts::CreateAction.call(params: { title: "Hello", body: "World" }, current_user: user)
144
+ result = Posts::CreateAction.create(params: { title: "Hello", body: "World" }, current_user: user)
141
145
 
142
146
  assert_Created(result)
143
147
  assert_equal user.id, result[:json][:data][:author_id]
@@ -145,9 +149,9 @@ class Posts::CreateActionTest < Minitest::Test
145
149
  end
146
150
  ```
147
151
 
148
- ### Testing a Custom Entry Point
152
+ ### Testing an Action with a Named Method
149
153
 
150
- When an action defines a custom class method (e.g., `.search`) instead of the default `.call`, call it by that name:
154
+ Call the action using its discovered method name:
151
155
 
152
156
  ```ruby
153
157
  class Products::SearchActionTest < Minitest::Test
@@ -156,8 +160,6 @@ class Products::SearchActionTest < Minitest::Test
156
160
  # class SearchAction
157
161
  # include ActionFigure[:jsend]
158
162
  #
159
- # entry_point :search
160
- #
161
163
  # params_schema do
162
164
  # required(:query).filled(:string)
163
165
  # end
@@ -187,14 +189,14 @@ class Sessions::DestroyActionTest < Minitest::Test
187
189
  # class Sessions::DestroyAction
188
190
  # include ActionFigure[:jsend]
189
191
  #
190
- # def call(session:)
192
+ # def destroy(session:)
191
193
  # session.destroy!
192
194
  # NoContent()
193
195
  # end
194
196
  # end
195
197
  def test_destroys_the_session
196
198
  session = sessions(:active)
197
- result = Sessions::DestroyAction.call(session: session)
199
+ result = Sessions::DestroyAction.destroy(session: session)
198
200
 
199
201
  assert_NoContent(result)
200
202
  end
@@ -224,7 +226,7 @@ result.failure? # => true
224
226
  result.errors.to_h # => { email: ["must be filled"] }
225
227
  ```
226
228
 
227
- This runs both the schema and any `rules` defined on the action -- the same validation pipeline that `.call` uses, without the side effects.
229
+ 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
230
 
229
231
  Actions that do not define a `params_schema` return `nil` from `.contract`.
230
232
 
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
 
@@ -36,13 +36,19 @@ 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 && @rules_block
47
+ raise ArgumentError,
48
+ "params_schema already defined with rules — " \
49
+ "redefining it would silently drop the existing rules block"
50
+ end
51
+
46
52
  @params_schema_block = block
47
53
  @contract = nil
48
54
  end
@@ -64,6 +70,7 @@ module ActionFigure
64
70
  "each action class may declare only one entry point"
65
71
  end
66
72
 
73
+ @explicit_entry_point = true
67
74
  @entry_point_name = name
68
75
  singleton_class.define_method(name) do |**kwargs|
69
76
  notify { new.validated_call(**kwargs) }
@@ -78,14 +85,6 @@ module ActionFigure
78
85
  value == :_unset ? @api_version : (@api_version = value)
79
86
  end
80
87
 
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
88
  def contract
90
89
  return nil unless @params_schema_block
91
90
 
@@ -99,6 +98,27 @@ module ActionFigure
99
98
  yield
100
99
  end
101
100
 
101
+ def method_added(name)
102
+ return if @explicit_entry_point
103
+ return unless public_method_defined?(name)
104
+ return unless instance_method(name).owner == self
105
+
106
+ if @entry_point_name
107
+ raise IndeterminantEntryPointError,
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
+
102
122
  def build_contract
103
123
  schema_block = @params_schema_block
104
124
  rules_block = @rules_block
@@ -115,7 +135,7 @@ module ActionFigure
115
135
  end
116
136
 
117
137
  def entry_point_name
118
- self.class.entry_point_name || :call
138
+ self.class.entry_point_name
119
139
  end
120
140
 
121
141
  def contract
@@ -123,12 +143,12 @@ module ActionFigure
123
143
  end
124
144
 
125
145
  def validated_call(**kwargs)
126
- raise ArgumentError, "params: passed but no params_schema defined" if kwargs.key?(:params) && !contract
146
+ kwargs = normalize_params(kwargs)
127
147
 
128
- if kwargs.key?(:params)
129
- call_with_params(**kwargs)
148
+ if contract && kwargs.key?(:params)
149
+ validate_and_call(**kwargs)
130
150
  else
131
- call_without_params(**kwargs)
151
+ public_send(entry_point_name, **kwargs)
132
152
  end
133
153
  end
134
154
 
@@ -149,18 +169,22 @@ module ActionFigure
149
169
 
150
170
  private
151
171
 
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)
172
+ def normalize_params(kwargs)
173
+ raw = kwargs[:params]
174
+ return kwargs unless raw.respond_to?(:to_unsafe_h)
175
+
176
+ kwargs.merge(params: raw.to_unsafe_h)
177
+ end
155
178
 
156
- result = contract.call(raw_params)
179
+ def validate_and_call(**kwargs)
180
+ result = contract.call(kwargs[:params])
157
181
 
158
182
  return UnprocessableContent(errors: result.errors.to_h) if result.failure?
159
183
 
160
- extra_params_error = check_extra_params(raw_params, result)
184
+ extra_params_error = check_extra_params(kwargs[:params], result)
161
185
  return extra_params_error if extra_params_error
162
186
 
163
- public_send(entry_point_name, **kwargs, params: result.to_h)
187
+ public_send(entry_point_name, **kwargs.except(:params), params: result.to_h)
164
188
  end
165
189
 
166
190
  def check_extra_params(raw_params, result)
@@ -172,9 +196,5 @@ module ActionFigure
172
196
  errors = extra_keys.to_h { |k| [k, ["is not allowed"]] }
173
197
  UnprocessableContent(errors: errors)
174
198
  end
175
-
176
- def call_without_params(**)
177
- public_send(entry_point_name, **)
178
- end
179
199
  end
180
200
  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,7 @@ 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
+ REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired].freeze
9
9
 
10
10
  def NoContent
11
11
  { 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
@@ -6,13 +6,11 @@ module ActionFigure
6
6
  # Simple resource serialization
7
7
  class Resource
8
8
  def self.serialize(resource)
9
- if resource.is_a?(Hash)
10
- resource
11
- elsif resource.respond_to?(:attributes)
9
+ if resource.respond_to?(:attributes)
12
10
  serialize_one(resource)
13
- elsif resource.respond_to?(:each)
11
+ elsif !resource.is_a?(Hash) && resource.respond_to?(:each)
14
12
  resource.map { |r| serialize(r) }
15
- else # rubocop:disable Lint/DuplicateBranch
13
+ else
16
14
  resource
17
15
  end
18
16
  end
@@ -38,6 +38,14 @@ module ActionFigure
38
38
  { json: { errors: convert_errors(errors, "403") }, status: :forbidden }
39
39
  end
40
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 }
47
+ end
48
+
41
49
  private
42
50
 
43
51
  def convert_errors(errors, status)
@@ -36,6 +36,14 @@ module ActionFigure
36
36
  def Forbidden(errors:)
37
37
  { json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
38
38
  end
39
+
40
+ def Conflict(errors:)
41
+ { json: { data: nil, errors: errors, status: "error" }, status: :conflict }
42
+ end
43
+
44
+ def PaymentRequired(errors:)
45
+ { json: { data: nil, errors: errors, status: "error" }, status: :payment_required }
46
+ end
39
47
  end
40
48
  end
41
49
  end