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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +140 -159
- data/docs/actions.md +68 -60
- data/docs/activesupport-notifications.md +12 -10
- data/docs/configuration.md +16 -2
- data/docs/custom-formatters.md +18 -6
- data/docs/response-formatters.md +159 -3
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +28 -10
- data/docs/validation.md +5 -1
- data/lib/action_figure/core.rb +81 -26
- data/lib/action_figure/format_registry.rb +4 -2
- data/lib/action_figure/formatter.rb +3 -1
- data/lib/action_figure/formatters/default.rb +15 -5
- data/lib/action_figure/formatters/jsend.rb +8 -0
- data/lib/action_figure/formatters/json_api/resource.rb +4 -6
- data/lib/action_figure/formatters/json_api.rb +21 -8
- data/lib/action_figure/formatters/wrapped.rb +8 -0
- data/lib/action_figure/testing/minitest.rb +8 -0
- data/lib/action_figure/testing/rspec.rb +33 -3
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +12 -1
- data/sig/action_figure.rbs +176 -1
- metadata +19 -2
data/docs/actions.md
CHANGED
|
@@ -6,9 +6,9 @@ An ActionFigure action class is a single-purpose operation. Each class encapsula
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Naming Your Action Method
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
ActionFigure auto-discovers your action method by name. Define one public instance method on your action class and ActionFigure registers it as the entry point -- no macro required:
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
14
|
class Users::CreateAction
|
|
@@ -21,7 +21,7 @@ class Users::CreateAction
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
24
|
+
def create(params:, company:, **)
|
|
25
25
|
user = company.users.create(params[:user])
|
|
26
26
|
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
27
27
|
|
|
@@ -30,21 +30,47 @@ class Users::CreateAction
|
|
|
30
30
|
end
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Wire it into a controller
|
|
33
|
+
Wire it into a controller using the discovered method name:
|
|
34
34
|
|
|
35
35
|
```ruby
|
|
36
36
|
class UsersController < ApplicationController
|
|
37
37
|
def create
|
|
38
|
-
render Users::CreateAction.
|
|
38
|
+
render Users::CreateAction.create(params:, company: current_company)
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
### How it works
|
|
44
|
+
|
|
45
|
+
ActionFigure uses a `method_added` hook to watch for public instance methods defined on the class. The first public method defined becomes the registered entry point and a matching class-level method is created for it. The full validation pipeline (`params_schema` and `rules`) still runs through the discovered entry point before your method is invoked.
|
|
46
|
+
|
|
47
|
+
Do not define **`initialize`** on action classes: ActionFigure calls **`new`** with no arguments each time work runs. A custom initializer raises **`InitializationNotSupportedError`** (even if `initialize` is private or you used **`entry_point`**). Prefer keyword arguments on the entry method or class-level collaborators for dependencies instead.
|
|
48
|
+
|
|
49
|
+
Overview of discovery (**`entry_point`** sidesteps ambiguity by wiring the singleton up front):
|
|
50
|
+
|
|
51
|
+
```mermaid
|
|
52
|
+
flowchart TD
|
|
53
|
+
A[include mixes Core + formatter] --> B["method_added fires for each new method"]
|
|
54
|
+
B --> C{"`entry_point` macro already declared?"}
|
|
55
|
+
C -->|"yes"| D[Skip auto-discovery;\nsingleton was defined by the macro]
|
|
56
|
+
C -->|"no"| E{"Public instance method owned by\nthis action class?"}
|
|
57
|
+
E -->|"no"| B
|
|
58
|
+
E -->|"yes"| F{"First discovered entry?"}
|
|
59
|
+
F -->|"yes"| G["Remember name;\ndefine .name(**kwargs) -> validated_call"]
|
|
60
|
+
F -->|"no"| H["Raise IndeterminateEntryPointError"]
|
|
61
|
+
```
|
|
44
62
|
|
|
45
|
-
|
|
63
|
+
### Disambiguation with `entry_point`
|
|
46
64
|
|
|
47
|
-
|
|
65
|
+
If a class ends up with more than one public instance method, ActionFigure cannot determine which one to use and raises an `IndeterminateEntryPointError`:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
ActionFigure::IndeterminateEntryPointError: Multiple public methods defined in Orders::SearchAction:
|
|
69
|
+
:search and :format_results. Either make one private or declare
|
|
70
|
+
`entry_point :search` to disambiguate.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use the `entry_point` macro to resolve this:
|
|
48
74
|
|
|
49
75
|
```ruby
|
|
50
76
|
class Orders::SearchAction
|
|
@@ -66,46 +92,34 @@ class Orders::SearchAction
|
|
|
66
92
|
resource = orders.as_json(only: %i[id tracking_number status])
|
|
67
93
|
Ok(resource:)
|
|
68
94
|
end
|
|
69
|
-
end
|
|
70
|
-
```
|
|
71
95
|
|
|
72
|
-
|
|
96
|
+
private
|
|
73
97
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def index
|
|
77
|
-
render Orders::SearchAction.search(params:, company: current_company)
|
|
98
|
+
def build_scope(company)
|
|
99
|
+
company.orders.active
|
|
78
100
|
end
|
|
79
101
|
end
|
|
80
102
|
```
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
- The instance method must match the declared entry point name (`:search` declares `.search` and expects `#search`). If an entry point is defined, any instance-level `#call` method is ignored by the class-level entry point.
|
|
85
|
-
- The full validation pipeline still runs through the custom entry point -- `params_schema` and `rules` are applied before your method is invoked.
|
|
86
|
-
- Calling `.call` on a class that declares a custom entry point raises a `NoMethodError` with a helpful message:
|
|
104
|
+
Only one entry point per class is allowed. A second `entry_point` declaration raises an `ArgumentError`:
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
- Only one entry point per class is allowed. A second `entry_point` declaration raises an `ArgumentError`:
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
ArgumentError: entry_point already defined as 'search' — each action class may declare only one entry point
|
|
96
|
-
```
|
|
106
|
+
```
|
|
107
|
+
ArgumentError: entry_point already defined as 'search' — each action class may declare only one entry point
|
|
108
|
+
```
|
|
97
109
|
|
|
98
110
|
---
|
|
99
111
|
|
|
100
|
-
##
|
|
112
|
+
## Actions Without a Schema
|
|
113
|
+
|
|
114
|
+
Actions that omit `params_schema` skip the validation pipeline entirely. Any `params:` passed through are delivered to your method as-is — no coercion, no stripping, no validation.
|
|
101
115
|
|
|
102
|
-
|
|
116
|
+
This is useful when validation is handled upstream (e.g., Rack middleware like `committee` validating against an OpenAPI spec) or when the action simply doesn't need params:
|
|
103
117
|
|
|
104
118
|
```ruby
|
|
105
119
|
class HealthCheckAction
|
|
106
120
|
include ActionFigure[:jsend]
|
|
107
121
|
|
|
108
|
-
def
|
|
122
|
+
def check
|
|
109
123
|
Ok(resource: { status: "healthy", time: Time.current })
|
|
110
124
|
end
|
|
111
125
|
end
|
|
@@ -114,17 +128,11 @@ end
|
|
|
114
128
|
```ruby
|
|
115
129
|
class HealthController < ApplicationController
|
|
116
130
|
def show
|
|
117
|
-
render HealthCheckAction.
|
|
131
|
+
render HealthCheckAction.check
|
|
118
132
|
end
|
|
119
133
|
end
|
|
120
134
|
```
|
|
121
135
|
|
|
122
|
-
If you accidentally pass `params:` to an action that has no schema, ActionFigure raises immediately:
|
|
123
|
-
|
|
124
|
-
```
|
|
125
|
-
ArgumentError: params: passed but no params_schema defined
|
|
126
|
-
```
|
|
127
|
-
|
|
128
136
|
---
|
|
129
137
|
|
|
130
138
|
## Context Injection
|
|
@@ -142,7 +150,7 @@ class Users::CreateAction
|
|
|
142
150
|
end
|
|
143
151
|
end
|
|
144
152
|
|
|
145
|
-
def
|
|
153
|
+
def create(params:, company:, current_user:)
|
|
146
154
|
user = company.users.create(params[:user].merge(invited_by: current_user))
|
|
147
155
|
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
148
156
|
|
|
@@ -154,7 +162,7 @@ end
|
|
|
154
162
|
```ruby
|
|
155
163
|
class UsersController < ApplicationController
|
|
156
164
|
def create
|
|
157
|
-
render Users::CreateAction.
|
|
165
|
+
render Users::CreateAction.create(
|
|
158
166
|
params:,
|
|
159
167
|
company: current_company,
|
|
160
168
|
current_user: current_user
|
|
@@ -175,7 +183,7 @@ A simple index action needs no params and no schema:
|
|
|
175
183
|
class Users::IndexAction
|
|
176
184
|
include ActionFigure[:jsend]
|
|
177
185
|
|
|
178
|
-
def
|
|
186
|
+
def index(company:)
|
|
179
187
|
users = company.users.order(:name)
|
|
180
188
|
Ok(resource: users.as_json(only: %i[id name email]))
|
|
181
189
|
end
|
|
@@ -185,7 +193,7 @@ end
|
|
|
185
193
|
```ruby
|
|
186
194
|
class UsersController < ApplicationController
|
|
187
195
|
def index
|
|
188
|
-
render Users::IndexAction.
|
|
196
|
+
render Users::IndexAction.index(company: current_company)
|
|
189
197
|
end
|
|
190
198
|
end
|
|
191
199
|
```
|
|
@@ -200,7 +208,7 @@ class Users::ShowAction
|
|
|
200
208
|
required(:id).filled(:integer)
|
|
201
209
|
end
|
|
202
210
|
|
|
203
|
-
def
|
|
211
|
+
def show(params:, company:)
|
|
204
212
|
user = company.users.find_by(id: params[:id])
|
|
205
213
|
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
206
214
|
|
|
@@ -212,7 +220,7 @@ end
|
|
|
212
220
|
```ruby
|
|
213
221
|
class UsersController < ApplicationController
|
|
214
222
|
def show
|
|
215
|
-
render Users::ShowAction.
|
|
223
|
+
render Users::ShowAction.show(params:, company: current_company)
|
|
216
224
|
end
|
|
217
225
|
end
|
|
218
226
|
```
|
|
@@ -230,7 +238,7 @@ class Users::CreateAction
|
|
|
230
238
|
end
|
|
231
239
|
end
|
|
232
240
|
|
|
233
|
-
def
|
|
241
|
+
def create(params:, company:)
|
|
234
242
|
user = company.users.create(params[:user])
|
|
235
243
|
return UnprocessableContent(errors: user.errors.messages) unless user.persisted?
|
|
236
244
|
|
|
@@ -242,7 +250,7 @@ end
|
|
|
242
250
|
```ruby
|
|
243
251
|
class UsersController < ApplicationController
|
|
244
252
|
def create
|
|
245
|
-
render Users::CreateAction.
|
|
253
|
+
render Users::CreateAction.create(params:, company: current_company)
|
|
246
254
|
end
|
|
247
255
|
end
|
|
248
256
|
```
|
|
@@ -261,7 +269,7 @@ class Users::UpdateAction
|
|
|
261
269
|
end
|
|
262
270
|
end
|
|
263
271
|
|
|
264
|
-
def
|
|
272
|
+
def update(params:, company:)
|
|
265
273
|
user = company.users.find_by(id: params[:id])
|
|
266
274
|
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
267
275
|
|
|
@@ -276,7 +284,7 @@ end
|
|
|
276
284
|
```ruby
|
|
277
285
|
class UsersController < ApplicationController
|
|
278
286
|
def update
|
|
279
|
-
render Users::UpdateAction.
|
|
287
|
+
render Users::UpdateAction.update(params:, company: current_company)
|
|
280
288
|
end
|
|
281
289
|
end
|
|
282
290
|
```
|
|
@@ -291,7 +299,7 @@ class Users::DestroyAction
|
|
|
291
299
|
required(:id).filled(:integer)
|
|
292
300
|
end
|
|
293
301
|
|
|
294
|
-
def
|
|
302
|
+
def destroy(params:, company:)
|
|
295
303
|
user = company.users.find_by(id: params[:id])
|
|
296
304
|
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
297
305
|
|
|
@@ -304,7 +312,7 @@ end
|
|
|
304
312
|
```ruby
|
|
305
313
|
class UsersController < ApplicationController
|
|
306
314
|
def destroy
|
|
307
|
-
render Users::DestroyAction.
|
|
315
|
+
render Users::DestroyAction.destroy(params:, company: current_company)
|
|
308
316
|
end
|
|
309
317
|
end
|
|
310
318
|
```
|
|
@@ -325,7 +333,7 @@ class Users::BulkInviteAction
|
|
|
325
333
|
required(:emails).value(:array, min_size?: 1).each(:str?)
|
|
326
334
|
end
|
|
327
335
|
|
|
328
|
-
def
|
|
336
|
+
def invite(params:, company:)
|
|
329
337
|
result = BulkInviteService.call(emails: params[:emails], company: company)
|
|
330
338
|
return UnprocessableContent(errors: result.errors) if result.failures?
|
|
331
339
|
|
|
@@ -337,7 +345,7 @@ end
|
|
|
337
345
|
```ruby
|
|
338
346
|
class Users::InvitesController < ApplicationController
|
|
339
347
|
def create
|
|
340
|
-
render Users::BulkInviteAction.
|
|
348
|
+
render Users::BulkInviteAction.invite(params:, company: current_company)
|
|
341
349
|
end
|
|
342
350
|
end
|
|
343
351
|
```
|
|
@@ -356,7 +364,7 @@ class Reports::GenerateAction
|
|
|
356
364
|
end
|
|
357
365
|
end
|
|
358
366
|
|
|
359
|
-
def
|
|
367
|
+
def generate(params:, current_user:)
|
|
360
368
|
result = ReportService.enqueue(params: params[:report], requested_by: current_user)
|
|
361
369
|
return UnprocessableContent(errors: result.errors) if result.failed?
|
|
362
370
|
|
|
@@ -368,7 +376,7 @@ end
|
|
|
368
376
|
```ruby
|
|
369
377
|
class ReportsController < ApplicationController
|
|
370
378
|
def create
|
|
371
|
-
render Reports::GenerateAction.
|
|
379
|
+
render Reports::GenerateAction.generate(params:, current_user: current_user)
|
|
372
380
|
end
|
|
373
381
|
end
|
|
374
382
|
```
|
|
@@ -379,7 +387,7 @@ File imports work the same way — receive the file, hand it off, translate the
|
|
|
379
387
|
class Products::ImportAction
|
|
380
388
|
include ActionFigure[:jsend]
|
|
381
389
|
|
|
382
|
-
def
|
|
390
|
+
def import(file:, company:)
|
|
383
391
|
result = ProductImportService.call(file: file, company: company)
|
|
384
392
|
return UnprocessableContent(errors: result.errors) if result.failed?
|
|
385
393
|
|
|
@@ -391,7 +399,7 @@ end
|
|
|
391
399
|
```ruby
|
|
392
400
|
class Products::ImportsController < ApplicationController
|
|
393
401
|
def create
|
|
394
|
-
render Products::ImportAction.
|
|
402
|
+
render Products::ImportAction.import(file: params[:file], company: current_company)
|
|
395
403
|
end
|
|
396
404
|
end
|
|
397
405
|
```
|
|
@@ -415,7 +423,7 @@ class Users::CreateAction
|
|
|
415
423
|
end
|
|
416
424
|
end
|
|
417
425
|
|
|
418
|
-
def
|
|
426
|
+
def create(params:)
|
|
419
427
|
user = User.create(params[:user])
|
|
420
428
|
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
421
429
|
|
|
@@ -435,7 +443,7 @@ Users::CreateAction.api_version #=> "2.0"
|
|
|
435
443
|
Inside an action instance, access it through the class:
|
|
436
444
|
|
|
437
445
|
```ruby
|
|
438
|
-
def
|
|
446
|
+
def create(params:, **)
|
|
439
447
|
if self.class.api_version == "2.0"
|
|
440
448
|
# v2 behavior
|
|
441
449
|
end
|
|
@@ -452,7 +460,7 @@ ActionFigure.configure do |config|
|
|
|
452
460
|
end
|
|
453
461
|
```
|
|
454
462
|
|
|
455
|
-
The global
|
|
463
|
+
The global value reads from **`ActionFigure.configuration.api_version`**. It never acts as an automatic fallback for **`api_version` on the class**: the two strings are intentionally separate. Use **`config.api_version`** for **infra-wide defaults** — release dashboards, outbound headers assembled in middleware, initializer documentation — without forcing each action constant to duplicate the same value. Put **`api_version "2.0"`** on classes when that action participates in explicit version branching. Versions are independent per class and **not inherited** by subclasses (state lives in class-level instance variables).
|
|
456
464
|
|
|
457
465
|
---
|
|
458
466
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
ActionFigure can provide notifications in action execution via `ActiveSupport::Notifications`. When enabled, every
|
|
5
|
+
ActionFigure can provide notifications in action execution via `ActiveSupport::Notifications`. When enabled, every class-level trigger (`:call`, `:create`, `:search`, etc.) emits a `process.action_figure` event with the action class name, entry-point symbol, outcome status, and timing.
|
|
6
6
|
|
|
7
7
|
Notifications are **off by default** and requires both ActiveSupport and an explicit opt-in.
|
|
8
8
|
|
|
@@ -30,10 +30,11 @@ process.action_figure
|
|
|
30
30
|
|
|
31
31
|
## Payload
|
|
32
32
|
|
|
33
|
-
| Key
|
|
34
|
-
|
|
35
|
-
| `action`
|
|
36
|
-
| `
|
|
33
|
+
| Key | Type | Description |
|
|
34
|
+
|---------------|--------|-------------|
|
|
35
|
+
| `action` | String | The action class name, e.g. `"Users::CreateAction"` |
|
|
36
|
+
| `entry_point` | Symbol | The dispatched instance method (`:call`, `:create`, `:search`, etc.). Always set when the event is emitted — events are only instrumented from the singleton method created during entry-point discovery, so `nil` is not observable in subscribers even though `ClassMethods#entry_point_name` is nullable internally (pre-discovery). |
|
|
37
|
+
| `status` | Symbol | The outcome status (set after completion), e.g. `:ok`, `:created` |
|
|
37
38
|
|
|
38
39
|
Timing (duration, start, end) is provided automatically by `ActiveSupport::Notifications`.
|
|
39
40
|
|
|
@@ -44,7 +45,7 @@ Timing (duration, start, end) is provided automatically by `ActiveSupport::Notif
|
|
|
44
45
|
```ruby
|
|
45
46
|
ActiveSupport::Notifications.subscribe("process.action_figure") do |event|
|
|
46
47
|
Rails.logger.info(
|
|
47
|
-
"#{event.payload[:action]} => #{event.payload[:status]} (#{event.duration.round(1)}ms)"
|
|
48
|
+
"#{event.payload[:action]}##{event.payload[:entry_point]} => #{event.payload[:status]} (#{event.duration.round(1)}ms)"
|
|
48
49
|
)
|
|
49
50
|
end
|
|
50
51
|
```
|
|
@@ -52,16 +53,16 @@ end
|
|
|
52
53
|
Output:
|
|
53
54
|
|
|
54
55
|
```
|
|
55
|
-
Users::CreateAction => :created (12.3ms)
|
|
56
|
-
Orders::SearchAction => :ok (45.7ms)
|
|
57
|
-
Users::CreateAction => :unprocessable_content (1.1ms)
|
|
56
|
+
Users::CreateAction#call => :created (12.3ms)
|
|
57
|
+
Orders::SearchAction#search => :ok (45.7ms)
|
|
58
|
+
Users::CreateAction#call => :unprocessable_content (1.1ms)
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
---
|
|
61
62
|
|
|
62
63
|
## What Gets Instrumented
|
|
63
64
|
|
|
64
|
-
The event wraps the entire action lifecycle
|
|
65
|
+
The event wraps the entire action lifecycle — validation, the entry-point instance method, and the formatted response. Both successful and failed outcomes are captured:
|
|
65
66
|
|
|
66
67
|
- Validation failures (e.g. missing required params) produce events with status `:unprocessable_content`
|
|
67
68
|
- Successful calls produce events with whatever status the action returns (`:ok`, `:created`, etc.)
|
|
@@ -90,6 +91,7 @@ ActiveSupport::Notifications.subscribe("process.action_figure") do |event|
|
|
|
90
91
|
event.duration,
|
|
91
92
|
tags: {
|
|
92
93
|
action: event.payload[:action],
|
|
94
|
+
entry_point: event.payload[:entry_point],
|
|
93
95
|
status: event.payload[:status]
|
|
94
96
|
}
|
|
95
97
|
)
|
data/docs/configuration.md
CHANGED
|
@@ -15,15 +15,29 @@ end
|
|
|
15
15
|
|
|
16
16
|
The block yields an `ActionFigure::Configuration::Settings` instance. Call any combination of setters inside.
|
|
17
17
|
|
|
18
|
+
## When configuration applies (load order)
|
|
19
|
+
|
|
20
|
+
**Default formatter.** With bare `include ActionFigure`, Ruby calls **`ActionFigure.[]`** (no argument) during that line — it mixes in whichever formatter **`ActionFigure.configuration.format`** selects **in that moment**. Later calls to **`ActionFigure.configure`** (changing **`format`**) do **not** swap formatters inside classes that already finished `include`. Run **`configure`** in an initializer (or equivalent) **before** your action classes load, or skip the ambiguity altogether with **`include ActionFigure[:jsonapi]`** (or another registered name).
|
|
21
|
+
|
|
22
|
+
**Notifications.** **`activesupport_notifications`** is consulted when the mixin’s **`included`** hook runs for your action class. If you turn **`c.activesupport_notifications = true`** only after constants have already loaded their `include` line, existing classes stay without the notifier extension; newly loaded classes get it.
|
|
23
|
+
|
|
24
|
+
**Per-class knobs** such as **`include ActionFigure[:wrapped]`**, **`entry_point :search`**, and **`api_version "2.0"`** remain whatever you wrote in each class regardless of subsequent global **`configure`** calls.
|
|
25
|
+
|
|
18
26
|
## Settings Reference
|
|
19
27
|
|
|
20
28
|
| Setting | Type | Default | Description |
|
|
21
29
|
|---------|------|---------|-------------|
|
|
22
|
-
| `format` | Symbol | `:default` |
|
|
30
|
+
| `format` | Symbol | `:default` | Formatter for bare **`include ActionFigure`**. Locked in when that line runs — see **When configuration applies (load order)** above. |
|
|
23
31
|
| `whiny_extra_params` | Boolean | `false` | When `true`, returns an error response for undeclared params instead of silently stripping them. |
|
|
24
|
-
| `activesupport_notifications` | Boolean | `false` | When `true
|
|
32
|
+
| `activesupport_notifications` | Boolean | `false` | When `true` and ActiveSupport is defined, emits **`process.action_figure`** for classes whose mixin runs **after** the flag was set — see load order note above. |
|
|
25
33
|
| `api_version` | String or nil | `nil` | Global API version tag, readable via `ActionFigure.configuration.api_version`. |
|
|
26
34
|
|
|
35
|
+
## Thread safety and global state
|
|
36
|
+
|
|
37
|
+
`ActionFigure.configure` assigns to a **process-wide singleton** (`ActionFigure.configuration`). For production, set globals **once during boot**. In **multi-threaded** code or parallel test workers, flipping settings concurrently can interfere across threads — snapshot and restore in `ensure` (as the gem’s tests do with `whiny_extra_params`) or avoid mutating globals after boot.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
27
41
|
## Registering Formatters via Config
|
|
28
42
|
|
|
29
43
|
You can register custom formatters inside the configure block with `register`:
|
data/docs/custom-formatters.md
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -48,8 +50,10 @@ module WrappedFormatter
|
|
|
48
50
|
{ json: body, status: :created }
|
|
49
51
|
end
|
|
50
52
|
|
|
51
|
-
def Accepted(resource: nil)
|
|
52
|
-
|
|
53
|
+
def Accepted(resource: nil, meta: nil)
|
|
54
|
+
body = { data: resource, errors: nil, status: "success" }
|
|
55
|
+
body[:meta] = meta if meta
|
|
56
|
+
{ json: body, status: :accepted }
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
def UnprocessableContent(errors:)
|
|
@@ -63,10 +67,18 @@ module WrappedFormatter
|
|
|
63
67
|
def Forbidden(errors:)
|
|
64
68
|
{ json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
|
|
65
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 }
|
|
77
|
+
end
|
|
66
78
|
end
|
|
67
79
|
```
|
|
68
80
|
|
|
69
|
-
All
|
|
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.
|
|
70
82
|
|
|
71
83
|
## Registering Your Formatter
|
|
72
84
|
|
|
@@ -103,7 +115,7 @@ Registration is not just bookkeeping -- ActionFigure validates every formatter m
|
|
|
103
115
|
|
|
104
116
|
```ruby
|
|
105
117
|
ActionFigure::Formatter::REQUIRED_METHODS
|
|
106
|
-
# => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden]
|
|
118
|
+
# => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden, :Conflict, :PaymentRequired]
|
|
107
119
|
```
|
|
108
120
|
|
|
109
121
|
If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent:
|
|
@@ -119,7 +131,7 @@ end
|
|
|
119
131
|
|
|
120
132
|
ActionFigure.register_formatter(incomplete: IncompleteFormatter)
|
|
121
133
|
# => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted,
|
|
122
|
-
# UnprocessableContent, NotFound, Forbidden
|
|
134
|
+
# UnprocessableContent, NotFound, Forbidden, Conflict, PaymentRequired
|
|
123
135
|
```
|
|
124
136
|
|
|
125
137
|
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.
|