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.
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
- ## The Default: `call`
9
+ ## Naming Your Action Method
10
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.
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 call(params:, company:, **)
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 by passing `params:` and any additional context:
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.call(params:, company: current_company)
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
- ## Custom Entry Points
63
+ ### Disambiguation with `entry_point`
46
64
 
47
- Some actions have a name that reads better than `.call`. The `entry_point` macro declares an alternative class-level method name:
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
- Call it from a controller using the declared name:
96
+ private
73
97
 
74
- ```ruby
75
- class OrdersController < ApplicationController
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
- ### 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:
104
+ Only one entry point per class is allowed. A second `entry_point` declaration raises an `ArgumentError`:
87
105
 
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
- ```
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
- ## No-Params Actions
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
- Actions that don't need validated input simply omit `params_schema`. The validation pipeline is skipped entirely.
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 call
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.call
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 call(params:, company:, current_user:)
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.call(
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 call(company:)
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.call(company: current_company)
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 call(params:, company:)
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.call(params:, company: current_company)
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 call(params:, company:)
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.call(params:, company: current_company)
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 call(params:, company:)
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.call(params:, company: current_company)
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 call(params:, company:)
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.call(params:, company: current_company)
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 call(params:, company:)
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.call(params:, company: current_company)
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 call(params:, current_user:)
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.call(params:, current_user: current_user)
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 call(file:, company:)
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.call(file: params[:file], company: current_company)
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 call(params:)
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 call(params:, **)
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 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.
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 `.call` (or custom entry point) emits a `process.action_figure` event with the action class name, outcome status, and timing.
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 | Type | Description |
34
- |----------|--------|-------------|
35
- | `action` | String | The action class name, e.g. `"Users::CreateAction"` |
36
- | `status` | Symbol | The outcome status, e.g. `:ok`, `:created` |
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 -- validation, the `#call` method, and the formatted response. Both successful and failed outcomes are captured:
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
  )
@@ -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` | Default formatter name. Applies to any class that uses bare `include ActionFigure`. |
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`, enables `ActiveSupport::Notifications` events for action classes defined after the change. Requires ActiveSupport. |
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`:
@@ -11,7 +11,7 @@ A formatter is a module that includes `ActionFigure::Formatter` and defines meth
11
11
  Including `ActionFigure::Formatter` gives you:
12
12
 
13
13
  - A default `NoContent` implementation that returns `{ status: :no_content }`.
14
- - A contract enforced at registration time: your module **must** define all 6 required methods.
14
+ - A contract enforced at registration time: your module **must** define all 8 required methods.
15
15
 
16
16
  The required methods are:
17
17
 
@@ -23,6 +23,8 @@ The required methods are:
23
23
  | `UnprocessableContent` | Validation or schema rule failure |
24
24
  | `NotFound` | Resource not found |
25
25
  | `Forbidden` | Authorization failure |
26
+ | `Conflict` | Resource state conflict or duplicate |
27
+ | `PaymentRequired` | Business billing or quota constraint |
26
28
 
27
29
  `NoContent` is provided by the base module and does not need to be defined, but you can override it if your format requires a different shape.
28
30
 
@@ -48,8 +50,10 @@ module WrappedFormatter
48
50
  { json: body, status: :created }
49
51
  end
50
52
 
51
- def Accepted(resource: nil)
52
- { json: { data: resource, errors: nil, status: "success" }, status: :accepted }
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 6 required methods are defined. Success methods accept `resource:` and optionally `meta:`, while failure methods accept `errors:`. The `NoContent` method is inherited from the base `ActionFigure::Formatter` module and returns `{ status: :no_content }` with no JSON body -- override it if your format requires a different shape.
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.