action_figure 0.1.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.
data/docs/actions.md ADDED
@@ -0,0 +1,495 @@
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
+ ## Naming Your Action Method
10
+
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
+
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 create(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 using the discovered method name:
34
+
35
+ ```ruby
36
+ class UsersController < ApplicationController
37
+ def create
38
+ render Users::CreateAction.create(params:, company: current_company)
39
+ end
40
+ end
41
+ ```
42
+
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
+ ### Disambiguation with `entry_point`
48
+
49
+ If a class ends up with more than one public instance method, ActionFigure cannot determine which one to use and raises an `IndeterminantEntryPointError`:
50
+
51
+ ```
52
+ ActionFigure::IndeterminantEntryPointError: Multiple public methods defined in Orders::SearchAction:
53
+ :search and :format_results. Either make one private or declare
54
+ `entry_point :search` to disambiguate.
55
+ ```
56
+
57
+ Use the `entry_point` macro to resolve this:
58
+
59
+ ```ruby
60
+ class Orders::SearchAction
61
+ include ActionFigure[:jsend]
62
+
63
+ entry_point :search
64
+
65
+ params_schema do
66
+ optional(:order_id).filled(:string)
67
+ optional(:tracking_number).filled(:string)
68
+ optional(:status).filled(:string)
69
+ end
70
+
71
+ def search(params:, company:)
72
+ orders = company.orders
73
+ orders = orders.where(id: params[:order_id]) if params[:order_id]
74
+ orders = orders.where(tracking_number: params[:tracking_number]) if params[:tracking_number]
75
+ orders = orders.where(status: params[:status]) if params[:status]
76
+ resource = orders.as_json(only: %i[id tracking_number status])
77
+ Ok(resource:)
78
+ end
79
+
80
+ private
81
+
82
+ def build_scope(company)
83
+ company.orders.active
84
+ end
85
+ end
86
+ ```
87
+
88
+ Only one entry point per class is allowed. A second `entry_point` declaration raises an `ArgumentError`:
89
+
90
+ ```
91
+ ArgumentError: entry_point already defined as 'search' — each action class may declare only one entry point
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Actions Without a Schema
97
+
98
+ 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.
99
+
100
+ 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:
101
+
102
+ ```ruby
103
+ class HealthCheckAction
104
+ include ActionFigure[:jsend]
105
+
106
+ def check
107
+ Ok(resource: { status: "healthy", time: Time.current })
108
+ end
109
+ end
110
+ ```
111
+
112
+ ```ruby
113
+ class HealthController < ApplicationController
114
+ def show
115
+ render HealthCheckAction.check
116
+ end
117
+ end
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Context Injection
123
+
124
+ 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.
125
+
126
+ ```ruby
127
+ class Users::CreateAction
128
+ include ActionFigure[:jsend]
129
+
130
+ params_schema do
131
+ required(:user).hash do
132
+ required(:email).filled(:string)
133
+ required(:name).filled(:string)
134
+ end
135
+ end
136
+
137
+ def create(params:, company:, current_user:)
138
+ user = company.users.create(params[:user].merge(invited_by: current_user))
139
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
140
+
141
+ Created(resource: user.as_json(only: %i[id name email]))
142
+ end
143
+ end
144
+ ```
145
+
146
+ ```ruby
147
+ class UsersController < ApplicationController
148
+ def create
149
+ render Users::CreateAction.create(
150
+ params:,
151
+ company: current_company,
152
+ current_user: current_user
153
+ )
154
+ end
155
+ end
156
+ ```
157
+
158
+ ---
159
+
160
+ ## CRUD Examples
161
+
162
+ ### Index
163
+
164
+ A simple index action needs no params and no schema:
165
+
166
+ ```ruby
167
+ class Users::IndexAction
168
+ include ActionFigure[:jsend]
169
+
170
+ def index(company:)
171
+ users = company.users.order(:name)
172
+ Ok(resource: users.as_json(only: %i[id name email]))
173
+ end
174
+ end
175
+ ```
176
+
177
+ ```ruby
178
+ class UsersController < ApplicationController
179
+ def index
180
+ render Users::IndexAction.index(company: current_company)
181
+ end
182
+ end
183
+ ```
184
+
185
+ ### Show
186
+
187
+ ```ruby
188
+ class Users::ShowAction
189
+ include ActionFigure[:jsend]
190
+
191
+ params_schema do
192
+ required(:id).filled(:integer)
193
+ end
194
+
195
+ def show(params:, company:)
196
+ user = company.users.find_by(id: params[:id])
197
+ return NotFound(errors: { base: ["user not found"] }) unless user
198
+
199
+ Ok(resource: user.as_json(only: %i[id name email]))
200
+ end
201
+ end
202
+ ```
203
+
204
+ ```ruby
205
+ class UsersController < ApplicationController
206
+ def show
207
+ render Users::ShowAction.show(params:, company: current_company)
208
+ end
209
+ end
210
+ ```
211
+
212
+ ### Create
213
+
214
+ ```ruby
215
+ class Users::CreateAction
216
+ include ActionFigure[:jsend]
217
+
218
+ params_schema do
219
+ required(:user).hash do
220
+ required(:name).filled(:string)
221
+ required(:email).filled(:string)
222
+ end
223
+ end
224
+
225
+ def create(params:, company:)
226
+ user = company.users.create(params[:user])
227
+ return UnprocessableContent(errors: user.errors.messages) unless user.persisted?
228
+
229
+ Created(resource: user.as_json(only: %i[id name email]))
230
+ end
231
+ end
232
+ ```
233
+
234
+ ```ruby
235
+ class UsersController < ApplicationController
236
+ def create
237
+ render Users::CreateAction.create(params:, company: current_company)
238
+ end
239
+ end
240
+ ```
241
+
242
+ ### Update
243
+
244
+ ```ruby
245
+ class Users::UpdateAction
246
+ include ActionFigure[:jsend]
247
+
248
+ params_schema do
249
+ required(:id).filled(:integer)
250
+ required(:user).hash do
251
+ optional(:name).filled(:string)
252
+ optional(:email).filled(:string)
253
+ end
254
+ end
255
+
256
+ def update(params:, company:)
257
+ user = company.users.find_by(id: params[:id])
258
+ return NotFound(errors: { base: ["user not found"] }) unless user
259
+
260
+ user.update(params[:user])
261
+ return UnprocessableContent(errors: user.errors.messages) unless user.errors.empty?
262
+
263
+ Ok(resource: user.as_json(only: %i[id name email]))
264
+ end
265
+ end
266
+ ```
267
+
268
+ ```ruby
269
+ class UsersController < ApplicationController
270
+ def update
271
+ render Users::UpdateAction.update(params:, company: current_company)
272
+ end
273
+ end
274
+ ```
275
+
276
+ ### Destroy
277
+
278
+ ```ruby
279
+ class Users::DestroyAction
280
+ include ActionFigure[:jsend]
281
+
282
+ params_schema do
283
+ required(:id).filled(:integer)
284
+ end
285
+
286
+ def destroy(params:, company:)
287
+ user = company.users.find_by(id: params[:id])
288
+ return NotFound(errors: { base: ["user not found"] }) unless user
289
+
290
+ user.destroy!
291
+ NoContent()
292
+ end
293
+ end
294
+ ```
295
+
296
+ ```ruby
297
+ class UsersController < ApplicationController
298
+ def destroy
299
+ render Users::DestroyAction.destroy(params:, company: current_company)
300
+ end
301
+ end
302
+ ```
303
+
304
+ For authorization, serialization, and pagination patterns, see [Integration Patterns](integration-patterns.md).
305
+
306
+ ---
307
+
308
+ ## Other Examples
309
+
310
+ 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:
311
+
312
+ ```ruby
313
+ class Users::BulkInviteAction
314
+ include ActionFigure[:jsend]
315
+
316
+ params_schema do
317
+ required(:emails).value(:array, min_size?: 1).each(:str?)
318
+ end
319
+
320
+ def invite(params:, company:)
321
+ result = BulkInviteService.call(emails: params[:emails], company: company)
322
+ return UnprocessableContent(errors: result.errors) if result.failures?
323
+
324
+ Created(resource: result.invitations)
325
+ end
326
+ end
327
+ ```
328
+
329
+ ```ruby
330
+ class Users::InvitesController < ApplicationController
331
+ def create
332
+ render Users::BulkInviteAction.invite(params:, company: current_company)
333
+ end
334
+ end
335
+ ```
336
+
337
+ Use `Accepted` when the real work happens asynchronously:
338
+
339
+ ```ruby
340
+ class Reports::GenerateAction
341
+ include ActionFigure[:jsend]
342
+
343
+ params_schema do
344
+ required(:report).hash do
345
+ required(:type).filled(:string)
346
+ optional(:start_date).filled(:date)
347
+ optional(:end_date).filled(:date)
348
+ end
349
+ end
350
+
351
+ def generate(params:, current_user:)
352
+ result = ReportService.enqueue(params: params[:report], requested_by: current_user)
353
+ return UnprocessableContent(errors: result.errors) if result.failed?
354
+
355
+ Accepted(resource: { id: result.report_id, status: "queued" })
356
+ end
357
+ end
358
+ ```
359
+
360
+ ```ruby
361
+ class ReportsController < ApplicationController
362
+ def create
363
+ render Reports::GenerateAction.generate(params:, current_user: current_user)
364
+ end
365
+ end
366
+ ```
367
+
368
+ File imports work the same way — receive the file, hand it off, translate the outcome:
369
+
370
+ ```ruby
371
+ class Products::ImportAction
372
+ include ActionFigure[:jsend]
373
+
374
+ def import(file:, company:)
375
+ result = ProductImportService.call(file: file, company: company)
376
+ return UnprocessableContent(errors: result.errors) if result.failed?
377
+
378
+ Ok(resource: { imported: result.imported_count, skipped: result.skipped_count })
379
+ end
380
+ end
381
+ ```
382
+
383
+ ```ruby
384
+ class Products::ImportsController < ApplicationController
385
+ def create
386
+ render Products::ImportAction.import(file: params[:file], company: current_company)
387
+ end
388
+ end
389
+ ```
390
+
391
+ ---
392
+
393
+ ## API Versioning
394
+
395
+ The `api_version` class macro attaches version metadata to an action class.
396
+
397
+ ```ruby
398
+ class Users::CreateAction
399
+ include ActionFigure[:jsend]
400
+
401
+ api_version "2.0"
402
+
403
+ params_schema do
404
+ required(:user).hash do
405
+ required(:email).filled(:string)
406
+ required(:name).filled(:string)
407
+ end
408
+ end
409
+
410
+ def create(params:)
411
+ user = User.create(params[:user])
412
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
413
+
414
+ Created(resource: user.as_json(only: %i[id name email]))
415
+ end
416
+ end
417
+ ```
418
+
419
+ ### Reading the version
420
+
421
+ Call `api_version` with no arguments to read the stored value:
422
+
423
+ ```ruby
424
+ Users::CreateAction.api_version #=> "2.0"
425
+ ```
426
+
427
+ Inside an action instance, access it through the class:
428
+
429
+ ```ruby
430
+ def create(params:, **)
431
+ if self.class.api_version == "2.0"
432
+ # v2 behavior
433
+ end
434
+ end
435
+ ```
436
+
437
+ ### Defaults
438
+
439
+ If no `api_version` is declared on a class, it returns `nil` by default. A global version is available through configuration:
440
+
441
+ ```ruby
442
+ ActionFigure.configure do |config|
443
+ config.api_version = "1.0"
444
+ end
445
+ ```
446
+
447
+ 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.
448
+
449
+ ---
450
+
451
+ ## File Conventions
452
+
453
+ Name action classes `ResourceName::VerbAction`. There are two common ways to organize them:
454
+
455
+ **Standalone directory** — action classes live in `app/actions/`:
456
+
457
+ ```
458
+ app/actions/
459
+ users/
460
+ create_action.rb
461
+ destroy_action.rb
462
+ index_action.rb
463
+ show_action.rb
464
+ update_action.rb
465
+ orders/
466
+ search_action.rb
467
+ cancel_action.rb
468
+ ```
469
+
470
+ **Alongside controllers** — action classes live next to the controllers that use them:
471
+
472
+ ```
473
+ app/controllers/
474
+ users_controller.rb
475
+ users/
476
+ create_action.rb
477
+ destroy_action.rb
478
+ index_action.rb
479
+ show_action.rb
480
+ update_action.rb
481
+ orders_controller.rb
482
+ orders/
483
+ search_action.rb
484
+ cancel_action.rb
485
+ ```
486
+
487
+ Both work with Rails autoloading. The second option keeps related code together — when you open a controller, its actions are right there.
488
+
489
+ ---
490
+
491
+ ## Design Constraints
492
+
493
+ 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.
494
+
495
+ 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
+ ```