action_figure 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE.txt +1 -1
- data/README.md +247 -16
- data/docs/actions.md +503 -0
- data/docs/activesupport-notifications.md +113 -0
- data/docs/configuration.md +88 -0
- data/docs/custom-formatters.md +175 -0
- data/docs/integration-patterns.md +331 -0
- data/docs/response-formatters.md +932 -0
- data/docs/testing.md +270 -0
- data/docs/validation.md +294 -0
- data/lib/action_figure/configuration.rb +33 -0
- data/lib/action_figure/core.rb +180 -0
- data/lib/action_figure/format_registry.rb +38 -0
- data/lib/action_figure/formatter.rb +14 -0
- data/lib/action_figure/formatters/default.rb +39 -0
- data/lib/action_figure/formatters/jsend.rb +41 -0
- data/lib/action_figure/formatters/json_api/resource.rb +32 -0
- data/lib/action_figure/formatters/json_api.rb +57 -0
- data/lib/action_figure/formatters/wrapped.rb +41 -0
- data/lib/action_figure/testing/minitest.rb +50 -0
- data/lib/action_figure/testing/rspec.rb +42 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +67 -0
- metadata +25 -5
data/docs/actions.md
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
# Actions
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
An ActionFigure action class is a single-purpose operation. Each class encapsulates one thing your application does -- creating a user, searching orders, processing a refund. This guide covers how to declare action classes, customize their entry points, inject dependencies, and wire them into your controllers.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The Default: `call`
|
|
10
|
+
|
|
11
|
+
Every action class gets a `.call` class method when it includes ActionFigure. It instantiates the class, runs the validation pipeline (if `params:` is provided), and delegates to the instance-level `#call` method.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class Users::CreateAction
|
|
15
|
+
include ActionFigure[:jsend]
|
|
16
|
+
|
|
17
|
+
params_schema do
|
|
18
|
+
required(:user).hash do
|
|
19
|
+
required(:email).filled(:string)
|
|
20
|
+
required(:name).filled(:string)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(params:, company:, **)
|
|
25
|
+
user = company.users.create(params[:user])
|
|
26
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
27
|
+
|
|
28
|
+
Created(resource: user.as_json(only: %i[id name email]))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Wire it into a controller by passing `params:` and any additional context:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
class UsersController < ApplicationController
|
|
37
|
+
def create
|
|
38
|
+
render Users::CreateAction.call(params:, company: current_company)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Custom Entry Points
|
|
46
|
+
|
|
47
|
+
Some actions have a name that reads better than `.call`. The `entry_point` macro declares an alternative class-level method name:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class Orders::SearchAction
|
|
51
|
+
include ActionFigure[:jsend]
|
|
52
|
+
|
|
53
|
+
entry_point :search
|
|
54
|
+
|
|
55
|
+
params_schema do
|
|
56
|
+
optional(:order_id).filled(:string)
|
|
57
|
+
optional(:tracking_number).filled(:string)
|
|
58
|
+
optional(:status).filled(:string)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def search(params:, company:)
|
|
62
|
+
orders = company.orders
|
|
63
|
+
orders = orders.where(id: params[:order_id]) if params[:order_id]
|
|
64
|
+
orders = orders.where(tracking_number: params[:tracking_number]) if params[:tracking_number]
|
|
65
|
+
orders = orders.where(status: params[:status]) if params[:status]
|
|
66
|
+
resource = orders.as_json(only: %i[id tracking_number status])
|
|
67
|
+
Ok(resource:)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Call it from a controller using the declared name:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class OrdersController < ApplicationController
|
|
76
|
+
def index
|
|
77
|
+
render Orders::SearchAction.search(params:, company: current_company)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### How it works
|
|
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:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
NoMethodError: undefined method 'call' for Orders::SearchAction (use 'search' instead)
|
|
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
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## No-Params Actions
|
|
101
|
+
|
|
102
|
+
Actions that don't need validated input simply omit `params_schema`. The validation pipeline is skipped entirely.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class HealthCheckAction
|
|
106
|
+
include ActionFigure[:jsend]
|
|
107
|
+
|
|
108
|
+
def call
|
|
109
|
+
Ok(resource: { status: "healthy", time: Time.current })
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class HealthController < ApplicationController
|
|
116
|
+
def show
|
|
117
|
+
render HealthCheckAction.call
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
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
|
+
---
|
|
129
|
+
|
|
130
|
+
## Context Injection
|
|
131
|
+
|
|
132
|
+
Non-`params:` keyword arguments pass through to the instance method untouched. This is how you inject context from the controller -- the current user, the tenant, a logger, or any other collaborator -- without any special DSL.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class Users::CreateAction
|
|
136
|
+
include ActionFigure[:jsend]
|
|
137
|
+
|
|
138
|
+
params_schema do
|
|
139
|
+
required(:user).hash do
|
|
140
|
+
required(:email).filled(:string)
|
|
141
|
+
required(:name).filled(:string)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def call(params:, company:, current_user:)
|
|
146
|
+
user = company.users.create(params[:user].merge(invited_by: current_user))
|
|
147
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
148
|
+
|
|
149
|
+
Created(resource: user.as_json(only: %i[id name email]))
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
class UsersController < ApplicationController
|
|
156
|
+
def create
|
|
157
|
+
render Users::CreateAction.call(
|
|
158
|
+
params:,
|
|
159
|
+
company: current_company,
|
|
160
|
+
current_user: current_user
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## CRUD Examples
|
|
169
|
+
|
|
170
|
+
### Index
|
|
171
|
+
|
|
172
|
+
A simple index action needs no params and no schema:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class Users::IndexAction
|
|
176
|
+
include ActionFigure[:jsend]
|
|
177
|
+
|
|
178
|
+
def call(company:)
|
|
179
|
+
users = company.users.order(:name)
|
|
180
|
+
Ok(resource: users.as_json(only: %i[id name email]))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
class UsersController < ApplicationController
|
|
187
|
+
def index
|
|
188
|
+
render Users::IndexAction.call(company: current_company)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Show
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class Users::ShowAction
|
|
197
|
+
include ActionFigure[:jsend]
|
|
198
|
+
|
|
199
|
+
params_schema do
|
|
200
|
+
required(:id).filled(:integer)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def call(params:, company:)
|
|
204
|
+
user = company.users.find_by(id: params[:id])
|
|
205
|
+
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
206
|
+
|
|
207
|
+
Ok(resource: user.as_json(only: %i[id name email]))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class UsersController < ApplicationController
|
|
214
|
+
def show
|
|
215
|
+
render Users::ShowAction.call(params:, company: current_company)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Create
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
class Users::CreateAction
|
|
224
|
+
include ActionFigure[:jsend]
|
|
225
|
+
|
|
226
|
+
params_schema do
|
|
227
|
+
required(:user).hash do
|
|
228
|
+
required(:name).filled(:string)
|
|
229
|
+
required(:email).filled(:string)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def call(params:, company:)
|
|
234
|
+
user = company.users.create(params[:user])
|
|
235
|
+
return UnprocessableContent(errors: user.errors.messages) unless user.persisted?
|
|
236
|
+
|
|
237
|
+
Created(resource: user.as_json(only: %i[id name email]))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class UsersController < ApplicationController
|
|
244
|
+
def create
|
|
245
|
+
render Users::CreateAction.call(params:, company: current_company)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Update
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
class Users::UpdateAction
|
|
254
|
+
include ActionFigure[:jsend]
|
|
255
|
+
|
|
256
|
+
params_schema do
|
|
257
|
+
required(:id).filled(:integer)
|
|
258
|
+
required(:user).hash do
|
|
259
|
+
optional(:name).filled(:string)
|
|
260
|
+
optional(:email).filled(:string)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def call(params:, company:)
|
|
265
|
+
user = company.users.find_by(id: params[:id])
|
|
266
|
+
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
267
|
+
|
|
268
|
+
user.update(params[:user])
|
|
269
|
+
return UnprocessableContent(errors: user.errors.messages) unless user.errors.empty?
|
|
270
|
+
|
|
271
|
+
Ok(resource: user.as_json(only: %i[id name email]))
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
class UsersController < ApplicationController
|
|
278
|
+
def update
|
|
279
|
+
render Users::UpdateAction.call(params:, company: current_company)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Destroy
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
class Users::DestroyAction
|
|
288
|
+
include ActionFigure[:jsend]
|
|
289
|
+
|
|
290
|
+
params_schema do
|
|
291
|
+
required(:id).filled(:integer)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def call(params:, company:)
|
|
295
|
+
user = company.users.find_by(id: params[:id])
|
|
296
|
+
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
297
|
+
|
|
298
|
+
user.destroy!
|
|
299
|
+
NoContent()
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
class UsersController < ApplicationController
|
|
306
|
+
def destroy
|
|
307
|
+
render Users::DestroyAction.call(params:, company: current_company)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
For authorization, serialization, and pagination patterns, see [Integration Patterns](integration-patterns.md).
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Other Examples
|
|
317
|
+
|
|
318
|
+
Actions aren't limited to CRUD. The pattern works anywhere you need to validate input, orchestrate work, and return a formatted response. In each case the action delegates to a service object and translates the result:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
class Users::BulkInviteAction
|
|
322
|
+
include ActionFigure[:jsend]
|
|
323
|
+
|
|
324
|
+
params_schema do
|
|
325
|
+
required(:emails).value(:array, min_size?: 1).each(:str?)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def call(params:, company:)
|
|
329
|
+
result = BulkInviteService.call(emails: params[:emails], company: company)
|
|
330
|
+
return UnprocessableContent(errors: result.errors) if result.failures?
|
|
331
|
+
|
|
332
|
+
Created(resource: result.invitations)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
class Users::InvitesController < ApplicationController
|
|
339
|
+
def create
|
|
340
|
+
render Users::BulkInviteAction.call(params:, company: current_company)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Use `Accepted` when the real work happens asynchronously:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
class Reports::GenerateAction
|
|
349
|
+
include ActionFigure[:jsend]
|
|
350
|
+
|
|
351
|
+
params_schema do
|
|
352
|
+
required(:report).hash do
|
|
353
|
+
required(:type).filled(:string)
|
|
354
|
+
optional(:start_date).filled(:date)
|
|
355
|
+
optional(:end_date).filled(:date)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def call(params:, current_user:)
|
|
360
|
+
result = ReportService.enqueue(params: params[:report], requested_by: current_user)
|
|
361
|
+
return UnprocessableContent(errors: result.errors) if result.failed?
|
|
362
|
+
|
|
363
|
+
Accepted(resource: { id: result.report_id, status: "queued" })
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
class ReportsController < ApplicationController
|
|
370
|
+
def create
|
|
371
|
+
render Reports::GenerateAction.call(params:, current_user: current_user)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
File imports work the same way — receive the file, hand it off, translate the outcome:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
class Products::ImportAction
|
|
380
|
+
include ActionFigure[:jsend]
|
|
381
|
+
|
|
382
|
+
def call(file:, company:)
|
|
383
|
+
result = ProductImportService.call(file: file, company: company)
|
|
384
|
+
return UnprocessableContent(errors: result.errors) if result.failed?
|
|
385
|
+
|
|
386
|
+
Ok(resource: { imported: result.imported_count, skipped: result.skipped_count })
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
class Products::ImportsController < ApplicationController
|
|
393
|
+
def create
|
|
394
|
+
render Products::ImportAction.call(file: params[:file], company: current_company)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## API Versioning
|
|
402
|
+
|
|
403
|
+
The `api_version` class macro attaches version metadata to an action class.
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
class Users::CreateAction
|
|
407
|
+
include ActionFigure[:jsend]
|
|
408
|
+
|
|
409
|
+
api_version "2.0"
|
|
410
|
+
|
|
411
|
+
params_schema do
|
|
412
|
+
required(:user).hash do
|
|
413
|
+
required(:email).filled(:string)
|
|
414
|
+
required(:name).filled(:string)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def call(params:)
|
|
419
|
+
user = User.create(params[:user])
|
|
420
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
421
|
+
|
|
422
|
+
Created(resource: user.as_json(only: %i[id name email]))
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Reading the version
|
|
428
|
+
|
|
429
|
+
Call `api_version` with no arguments to read the stored value:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
Users::CreateAction.api_version #=> "2.0"
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Inside an action instance, access it through the class:
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
def call(params:, **)
|
|
439
|
+
if self.class.api_version == "2.0"
|
|
440
|
+
# v2 behavior
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Defaults
|
|
446
|
+
|
|
447
|
+
If no `api_version` is declared on a class, it returns `nil` by default. A global version is available through configuration:
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
ActionFigure.configure do |config|
|
|
451
|
+
config.api_version = "1.0"
|
|
452
|
+
end
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The global version is accessible via `ActionFigure.configuration.api_version` but is not used as an automatic fallback for class-level `api_version`. Version values are independent per class -- they are not inherited by subclasses because version state is stored in class-level instance variables.
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## File Conventions
|
|
460
|
+
|
|
461
|
+
Name action classes `ResourceName::VerbAction`. There are two common ways to organize them:
|
|
462
|
+
|
|
463
|
+
**Standalone directory** — action classes live in `app/actions/`:
|
|
464
|
+
|
|
465
|
+
```
|
|
466
|
+
app/actions/
|
|
467
|
+
users/
|
|
468
|
+
create_action.rb
|
|
469
|
+
destroy_action.rb
|
|
470
|
+
index_action.rb
|
|
471
|
+
show_action.rb
|
|
472
|
+
update_action.rb
|
|
473
|
+
orders/
|
|
474
|
+
search_action.rb
|
|
475
|
+
cancel_action.rb
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Alongside controllers** — action classes live next to the controllers that use them:
|
|
479
|
+
|
|
480
|
+
```
|
|
481
|
+
app/controllers/
|
|
482
|
+
users_controller.rb
|
|
483
|
+
users/
|
|
484
|
+
create_action.rb
|
|
485
|
+
destroy_action.rb
|
|
486
|
+
index_action.rb
|
|
487
|
+
show_action.rb
|
|
488
|
+
update_action.rb
|
|
489
|
+
orders_controller.rb
|
|
490
|
+
orders/
|
|
491
|
+
search_action.rb
|
|
492
|
+
cancel_action.rb
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Both work with Rails autoloading. The second option keeps related code together — when you open a controller, its actions are right there.
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Design Constraints
|
|
500
|
+
|
|
501
|
+
Action classes are intentionally flat. The class-level state that powers `params_schema`, `rules`, and `entry_point` is stored in **class-level instance variables** and is **not inherited** by subclasses. If you subclass an action, the child class starts with a blank slate -- no schema, no rules, no custom entry point.
|
|
502
|
+
|
|
503
|
+
This is by design. Each action class should be a self-contained, independently readable unit. If you find yourself wanting to share behavior across actions, extract shared logic into plain Ruby modules or service objects and compose them explicitly.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ActiveSupport Notifications
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
ActionFigure can provide notifications in action execution via `ActiveSupport::Notifications`. When enabled, every `.call` (or custom entry point) emits a `process.action_figure` event with the action class name, outcome status, and timing.
|
|
6
|
+
|
|
7
|
+
Notifications are **off by default** and requires both ActiveSupport and an explicit opt-in.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Enabling ActiveSupport Notifications
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
ActionFigure.configure do |c|
|
|
15
|
+
c.activesupport_notifications = true
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Because notification is resolved at include-time (when a class calls `include ActionFigure`), this setting must be configured before your action classes are loaded -- typically in an initializer.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Event Name
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
process.action_figure
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Payload
|
|
32
|
+
|
|
33
|
+
| Key | Type | Description |
|
|
34
|
+
|----------|--------|-------------|
|
|
35
|
+
| `action` | String | The action class name, e.g. `"Users::CreateAction"` |
|
|
36
|
+
| `status` | Symbol | The outcome status, e.g. `:ok`, `:created` |
|
|
37
|
+
|
|
38
|
+
Timing (duration, start, end) is provided automatically by `ActiveSupport::Notifications`.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Subscribing to ActionFigure Events
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
ActiveSupport::Notifications.subscribe("process.action_figure") do |event|
|
|
46
|
+
Rails.logger.info(
|
|
47
|
+
"#{event.payload[:action]} => #{event.payload[:status]} (#{event.duration.round(1)}ms)"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Output:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Users::CreateAction => :created (12.3ms)
|
|
56
|
+
Orders::SearchAction => :ok (45.7ms)
|
|
57
|
+
Users::CreateAction => :unprocessable_content (1.1ms)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## What Gets Instrumented
|
|
63
|
+
|
|
64
|
+
The event wraps the entire action lifecycle -- validation, the `#call` method, and the formatted response. Both successful and failed outcomes are captured:
|
|
65
|
+
|
|
66
|
+
- Validation failures (e.g. missing required params) produce events with status `:unprocessable_content`
|
|
67
|
+
- Successful calls produce events with whatever status the action returns (`:ok`, `:created`, etc.)
|
|
68
|
+
- Custom entry points (declared with `entry_point`) are instrumented identically to `.call`
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Examples
|
|
73
|
+
|
|
74
|
+
### Logging slow actions
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
ActiveSupport::Notifications.subscribe("process.action_figure") do |event|
|
|
78
|
+
if event.duration > 500
|
|
79
|
+
Rails.logger.warn("[SLOW] #{event.payload[:action]} took #{event.duration.round}ms")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Tracking metrics
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
ActiveSupport::Notifications.subscribe("process.action_figure") do |event|
|
|
88
|
+
StatsD.distribution(
|
|
89
|
+
"action_figure.duration",
|
|
90
|
+
event.duration,
|
|
91
|
+
tags: {
|
|
92
|
+
action: event.payload[:action],
|
|
93
|
+
status: event.payload[:status]
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Counting failures
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
ActiveSupport::Notifications.subscribe("process.action_figure") do |event|
|
|
103
|
+
unless event.payload[:status] == :ok || event.payload[:status] == :created
|
|
104
|
+
ErrorTracker.increment("action_figure.failure", action: event.payload[:action])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## How It Works
|
|
112
|
+
|
|
113
|
+
Notification setup is resolved at include-time, not on every call. When a class includes an ActionFigure module (e.g. `include ActionFigure[:jsend]`), the `included` hook checks whether `ActiveSupport::Notifications` is defined and `activesupport_notifications` is enabled in the configuration. If both conditions are met, it extends the class with `ActionFigure::Core::Notifications`, which overrides the base `notify` method to wrap execution in an `ActiveSupport::Notifications.instrument` block. Otherwise, the base method passes through directly with zero overhead.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
ActionFigure exposes global defaults through `ActionFigure.configure`. Every setting can be overridden on a per-class basis, so the global configuration acts as a baseline for your application.
|
|
6
|
+
|
|
7
|
+
## Configuration Block
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
ActionFigure.configure do |c|
|
|
11
|
+
c.format = :jsend
|
|
12
|
+
c.whiny_extra_params = true
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The block yields an `ActionFigure::Configuration::Settings` instance. Call any combination of setters inside.
|
|
17
|
+
|
|
18
|
+
## Settings Reference
|
|
19
|
+
|
|
20
|
+
| Setting | Type | Default | Description |
|
|
21
|
+
|---------|------|---------|-------------|
|
|
22
|
+
| `format` | Symbol | `:default` | Default formatter name. Applies to any class that uses bare `include ActionFigure`. |
|
|
23
|
+
| `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`, enables `ActiveSupport::Notifications` events for action classes defined after the change. Requires ActiveSupport. |
|
|
25
|
+
| `api_version` | String or nil | `nil` | Global API version tag, readable via `ActionFigure.configuration.api_version`. |
|
|
26
|
+
|
|
27
|
+
## Registering Formatters via Config
|
|
28
|
+
|
|
29
|
+
You can register custom formatters inside the configure block with `register`:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
ActionFigure.configure do |c|
|
|
33
|
+
c.register(custom_format: MyApp::CustomApiFormatter)
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This delegates to `ActionFigure.register_formatter`, making the `:custom_format` format available application-wide. Once registered, set it as the default or use it per-class:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
c.format = :custom_format
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Per-Class Overrides
|
|
44
|
+
|
|
45
|
+
**Format** -- Pass the desired format when including the module:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class Orders::CreateAction
|
|
49
|
+
include ActionFigure[:jsonapi]
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This overrides the global `format` for that single class, regardless of what `ActionFigure.configure` specifies.
|
|
54
|
+
|
|
55
|
+
**API version** -- Declare a version inside the class body:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class Orders::CreateAction
|
|
59
|
+
include ActionFigure
|
|
60
|
+
|
|
61
|
+
api_version "2.0"
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This sets the API version for that class. Class-level versions are independent of the global `api_version` setting.
|
|
66
|
+
|
|
67
|
+
## Rails Initializer Example
|
|
68
|
+
|
|
69
|
+
An example `config/initializers/action_figure.rb`:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
ActionFigure.configure do |c|
|
|
73
|
+
# Reject unexpected params with an error response (recommended for development)
|
|
74
|
+
c.whiny_extra_params = Rails.env.local?
|
|
75
|
+
|
|
76
|
+
# Tag all actions with the current API version
|
|
77
|
+
c.api_version = "1.0"
|
|
78
|
+
|
|
79
|
+
# Turn on ActiveSupport::Notifications events for every action call
|
|
80
|
+
c.activesupport_notifications = true
|
|
81
|
+
|
|
82
|
+
# Register a custom formatter
|
|
83
|
+
c.register(our_format: MyApp::OurFormatter)
|
|
84
|
+
|
|
85
|
+
# Use the custom formatter by default
|
|
86
|
+
c.format = :our_format
|
|
87
|
+
end
|
|
88
|
+
```
|