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.
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
+ ```