action_figure 0.5.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.
- checksums.yaml +4 -4
- data/README.md +115 -143
- data/docs/actions.md +51 -59
- data/docs/custom-formatters.md +14 -4
- data/docs/response-formatters.md +159 -3
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +12 -10
- data/docs/validation.md +1 -1
- data/lib/action_figure/core.rb +44 -24
- data/lib/action_figure/format_registry.rb +4 -2
- data/lib/action_figure/formatter.rb +1 -1
- data/lib/action_figure/formatters/default.rb +15 -5
- data/lib/action_figure/formatters/jsend.rb +8 -0
- data/lib/action_figure/formatters/json_api/resource.rb +3 -5
- data/lib/action_figure/formatters/json_api.rb +8 -0
- data/lib/action_figure/formatters/wrapped.rb +8 -0
- data/lib/action_figure/testing/minitest.rb +8 -0
- data/lib/action_figure/testing/rspec.rb +5 -3
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +2 -1
- data/sig/action_figure.rbs +157 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c29461ca24fe48d0143c6c861a738e37fa04fa9954b0fcc3336f8154dc16a30
|
|
4
|
+
data.tar.gz: 93e527838e129363aafbba74751660831a54084cb159571fa71f0b9ef11322fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 311b1a051ee7caec1aece62143607ed2484720ab6a04398b66f091087a0febccacc91e1478c438bbca98b93d0d13cf57da6f12d8ae3caf47dd8bbb0b642c7994
|
|
7
|
+
data.tar.gz: 4bd0d145727af9f7373301ad26abc7b65a08041071c07317cf3fb88a818961be09e834de1b857a5256c6012b2965e0aa2bf1da24943cd2219689416d14be5a22
|
data/README.md
CHANGED
|
@@ -5,127 +5,27 @@ Fully-articulated controller actions.
|
|
|
5
5
|
---
|
|
6
6
|
> #### Table of Contents
|
|
7
7
|
> [Installation](#installation)<br>
|
|
8
|
-
> [Quick Start](#quick-start)<br>
|
|
9
8
|
> [How It Works](#how-it-works)<br>
|
|
10
9
|
> [Features](#features)<br>
|
|
11
|
-
> [
|
|
10
|
+
> [Quick Start](#quick-start)<br>
|
|
12
11
|
> [Design Philosophy](#design-philosophy)<br>
|
|
13
12
|
> [Requirements](#requirements)<br>
|
|
14
13
|
> [License](#license)
|
|
15
14
|
---
|
|
16
15
|
|
|
17
|
-
**ActionFigure**
|
|
18
|
-
|
|
19
|
-
## Installation
|
|
20
|
-
|
|
21
|
-
Add to your Gemfile and `bundle install`:
|
|
22
|
-
|
|
23
|
-
```ruby
|
|
24
|
-
gem "action_figure"
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Quick Start
|
|
28
|
-
|
|
29
|
-
**1. Start with what the action should do.**
|
|
30
|
-
|
|
31
|
-
```ruby
|
|
32
|
-
# spec/actions/users/create_action_spec.rb
|
|
33
|
-
RSpec.describe Users::CreateAction do
|
|
34
|
-
it "creates a user with valid parameters" do
|
|
35
|
-
company = Company.create!(name: "Acme")
|
|
36
|
-
|
|
37
|
-
# Note: Extra keyword arguments like company: are injected as context alongside params:
|
|
38
|
-
result = Users::CreateAction.call(
|
|
39
|
-
params: { user: { name: "Tad", email: "tad@example.com" } },
|
|
40
|
-
company: company
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
# Results are render-ready hashes (JSend formatted in this case)
|
|
44
|
-
# => { json: { status: "success", data: { name: "Tad", ... } }, status: :created }
|
|
45
|
-
expect(result).to be_Created
|
|
46
|
-
expect(result[:json][:data]).to include("name" => "Tad", "email" => "tad@example.com")
|
|
47
|
-
expect(User.find_by(email: "tad@example.com")).to be_persisted
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
it "fails when name is missing" do
|
|
51
|
-
company = Company.create!(name: "Acme")
|
|
52
|
-
result = Users::CreateAction.call(
|
|
53
|
-
params: { user: { email: "tad@example.com" } },
|
|
54
|
-
company: company
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
# Validation failures short-circuit before #call executes
|
|
58
|
-
# => { json: { status: "fail", data: { user: { name: ["is missing"] } } },
|
|
59
|
-
# status: :unprocessable_content }
|
|
60
|
-
expect(result).to be_UnprocessableContent
|
|
61
|
-
expect(result[:json][:data][:user][:name]).to include("is missing")
|
|
62
|
-
expect(User.find_by(email: "tad@example.com")).not_to be_persisted
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
**2. Define the action class.**
|
|
68
|
-
|
|
69
|
-
```ruby
|
|
70
|
-
# app/actions/users/create_action.rb
|
|
71
|
-
class Users::CreateAction
|
|
72
|
-
include ActionFigure[:jsend]
|
|
73
|
-
|
|
74
|
-
params_schema do
|
|
75
|
-
required(:user).hash do
|
|
76
|
-
required(:name).filled(:string)
|
|
77
|
-
required(:email).filled(:string)
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def call(params:, company:)
|
|
82
|
-
user = company.users.create(params[:user])
|
|
83
|
-
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
84
|
-
|
|
85
|
-
Created(resource: user.as_json(only: %i[id name email]))
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
**3. Call it from your controller.**
|
|
16
|
+
**ActionFigure** extracts controller actions into classes that validate params, orchestrate work, and return render-ready responses. Your controller becomes:
|
|
91
17
|
|
|
92
18
|
```ruby
|
|
93
|
-
class
|
|
19
|
+
class OrdersController < ApplicationController
|
|
94
20
|
def create
|
|
95
|
-
render
|
|
21
|
+
render Orders::CreateAction.create(params:, current_user:)
|
|
96
22
|
end
|
|
97
23
|
end
|
|
98
24
|
```
|
|
99
25
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
Every action class has three responsibilities:
|
|
103
|
-
|
|
104
|
-
1. **Check params** — `params_schema` validates structure and types, `rules` enforces validation rules. If either fails, the formatter returns an error response and `#call` is never invoked.
|
|
105
|
-
2. **Orchestrate** — `#call` coordinates the work: creating records, calling service objects, enqueuing jobs, or anything else your operation requires. The action is the entry point, not necessarily where all the logic lives.
|
|
106
|
-
3. **Return a formatted response** — response helpers like `Created(resource:)` and `NotFound(errors:)` return render-ready hashes that go straight to `render` in your controller.
|
|
107
|
-
|
|
108
|
-
## Features
|
|
109
|
-
|
|
110
|
-
| Feature | Description |
|
|
111
|
-
|---------|-------------|
|
|
112
|
-
| [Validation](docs/validation.md) | Two-layer validation powered by dry-validation: structural schemas with type coercion, plus validation rules. Includes cross-parameter helpers like `one_rule`, `all_rule`, and `implies_rule`. |
|
|
113
|
-
| [Response Formatters](docs/response-formatters.md) | Four built-in formats: Default, JSend, JSON:API, and Wrapped. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. |
|
|
114
|
-
| [Custom Formatters](docs/custom-formatters.md) | Define your own response envelope by implementing the formatter interface. Registration validates your module at load time. |
|
|
115
|
-
| [Actions](docs/actions.md) | Custom entry points (`entry_point :search`), context injection via keyword arguments, per-class API versioning, and no-params actions. |
|
|
116
|
-
| [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
|
|
117
|
-
| [Notifications](docs/activesupport-notifications.md) | Opt-in `ActiveSupport::Notifications` events for every action call. Emits action class, outcome status, and duration on the `process.action_figure` event. |
|
|
118
|
-
| [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
|
|
119
|
-
| [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
|
|
120
|
-
|
|
121
|
-
## Full Example
|
|
122
|
-
|
|
123
|
-
Here is a more complete action showing how validation, authorization, and response formatting work together.
|
|
124
|
-
|
|
125
|
-
**The action class:**
|
|
26
|
+
The action class owns everything that used to be scattered across the controller method, strong params, model callbacks, and ad-hoc response building:
|
|
126
27
|
|
|
127
28
|
```ruby
|
|
128
|
-
# app/actions/orders/create_action.rb
|
|
129
29
|
class Orders::CreateAction
|
|
130
30
|
include ActionFigure[:wrapped]
|
|
131
31
|
|
|
@@ -142,7 +42,7 @@ class Orders::CreateAction
|
|
|
142
42
|
"gift fields must be provided together or not at all")
|
|
143
43
|
end
|
|
144
44
|
|
|
145
|
-
def
|
|
45
|
+
def create(params:, current_user:)
|
|
146
46
|
if current_user.unpaid_balance?
|
|
147
47
|
return Forbidden(errors: { base: ["unpaid balance on account"] })
|
|
148
48
|
end
|
|
@@ -163,22 +63,9 @@ class Orders::CreateAction
|
|
|
163
63
|
end
|
|
164
64
|
```
|
|
165
65
|
|
|
166
|
-
|
|
66
|
+
Param validation, cross-field rules, authorization, error handling, and response formatting — all in one place, all testable without a request:
|
|
167
67
|
|
|
168
68
|
```ruby
|
|
169
|
-
class OrdersController < ApplicationController
|
|
170
|
-
def create
|
|
171
|
-
render Orders::CreateAction.call(params:, current_user:)
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
**Testing it:**
|
|
177
|
-
|
|
178
|
-
```ruby
|
|
179
|
-
# test/actions/orders/create_action_test.rb
|
|
180
|
-
require "action_figure/testing/minitest"
|
|
181
|
-
|
|
182
69
|
class Orders::CreateActionTest < Minitest::Test
|
|
183
70
|
include ActionFigure::Testing::Minitest
|
|
184
71
|
|
|
@@ -186,20 +73,19 @@ class Orders::CreateActionTest < Minitest::Test
|
|
|
186
73
|
user = User.create!(name: "Tad")
|
|
187
74
|
item = Item.create!(name: "Widget", price: 29.00)
|
|
188
75
|
|
|
189
|
-
result = Orders::CreateAction.
|
|
76
|
+
result = Orders::CreateAction.create(
|
|
190
77
|
params: { item_id: item.id, quantity: 2 },
|
|
191
78
|
current_user: user
|
|
192
79
|
)
|
|
193
80
|
|
|
194
81
|
assert_Created(result)
|
|
195
82
|
assert_equal item.id, result[:json][:data]["item_id"]
|
|
196
|
-
assert_equal 2, result[:json][:data]["quantity"]
|
|
197
83
|
end
|
|
198
84
|
|
|
199
85
|
def test_forbidden_with_unpaid_balance
|
|
200
|
-
user = User.create!(name: "
|
|
86
|
+
user = User.create!(name: "Tad", balance: -1)
|
|
201
87
|
|
|
202
|
-
result = Orders::CreateAction.
|
|
88
|
+
result = Orders::CreateAction.create(
|
|
203
89
|
params: { item_id: 1, quantity: 1 },
|
|
204
90
|
current_user: user
|
|
205
91
|
)
|
|
@@ -211,7 +97,7 @@ class Orders::CreateActionTest < Minitest::Test
|
|
|
211
97
|
def test_not_found_when_item_missing
|
|
212
98
|
user = User.create!(name: "Tad")
|
|
213
99
|
|
|
214
|
-
result = Orders::CreateAction.
|
|
100
|
+
result = Orders::CreateAction.create(
|
|
215
101
|
params: { item_id: 999, quantity: 1 },
|
|
216
102
|
current_user: user
|
|
217
103
|
)
|
|
@@ -220,49 +106,135 @@ class Orders::CreateActionTest < Minitest::Test
|
|
|
220
106
|
assert_includes result[:json][:errors][:item_id], "item not found"
|
|
221
107
|
end
|
|
222
108
|
|
|
223
|
-
def
|
|
109
|
+
def test_rejects_partial_gift_fields
|
|
224
110
|
user = User.create!(name: "Tad")
|
|
225
|
-
item = Item.create!(name: "Widget", price: 29.00
|
|
111
|
+
item = Item.create!(name: "Widget", price: 29.00)
|
|
226
112
|
|
|
227
|
-
result = Orders::CreateAction.
|
|
228
|
-
params: { item_id: item.id, quantity:
|
|
113
|
+
result = Orders::CreateAction.create(
|
|
114
|
+
params: { item_id: item.id, quantity: 1, gift_message: "Enjoy!" },
|
|
229
115
|
current_user: user
|
|
230
116
|
)
|
|
231
117
|
|
|
232
118
|
assert_UnprocessableContent(result)
|
|
233
|
-
assert_includes result[:json][:errors][:
|
|
119
|
+
assert_includes result[:json][:errors][:gift_message],
|
|
120
|
+
"gift fields must be provided together or not at all"
|
|
234
121
|
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
235
124
|
|
|
236
|
-
|
|
237
|
-
user = User.create!(name: "Tad")
|
|
238
|
-
item = Item.create!(name: "Widget", price: 29.00)
|
|
125
|
+
This isn't for everybody. If your controllers are already thin, or you validate through OpenAPI middleware like [committee](https://github.com/interagent/committee), you probably don't need this. ActionFigure is for teams whose controller actions have grown into tangled mixes of param wrangling, authorization checks, error handling, and response building.
|
|
239
126
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
127
|
+
## Installation
|
|
128
|
+
|
|
129
|
+
Add to your Gemfile and `bundle install`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
gem "action_figure"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## How It Works
|
|
136
|
+
|
|
137
|
+
Every action class has three responsibilities:
|
|
138
|
+
|
|
139
|
+
1. **Check params** (optional) — when a `params_schema` is defined, it validates structure and types; `rules` enforces validation rules. If either fails, the formatter returns an error response and your action method is never invoked. Actions without a schema receive `params:` as-is.
|
|
140
|
+
2. **Orchestrate** — your action method coordinates the work: creating records, calling service objects, enqueuing jobs, or anything else the action requires. The action is the entry point, not necessarily where all the logic lives.
|
|
141
|
+
3. **Return a formatted response** — response helpers like `Created(resource:)` and `NotFound(errors:)` return render-ready hashes that go straight to `render` in your controller.
|
|
142
|
+
|
|
143
|
+
## Features
|
|
144
|
+
|
|
145
|
+
| Feature | Description |
|
|
146
|
+
|---------|-------------|
|
|
147
|
+
| [Validation](docs/validation.md) | Two-layer validation powered by dry-validation: structural schemas with type coercion, plus validation rules. Includes cross-parameter helpers like `exclusive_rule`, `any_rule`, `one_rule`, and `all_rule`. |
|
|
148
|
+
| [Response Formatters](docs/response-formatters.md) | Four built-in formats: Default, JSend, JSON:API, and Wrapped. Each provides response helpers (`Ok`, `Created`, `NotFound`, etc.) that return render-ready hashes. |
|
|
149
|
+
| [Status Codes](docs/status-codes.md) | Which 4xx codes are domain concerns (handled by action classes) vs perimeter concerns (handled by middleware, router, or infrastructure). |
|
|
150
|
+
| [Custom Formatters](docs/custom-formatters.md) | Define your own response envelope by implementing the formatter interface. Registration validates your module at load time. |
|
|
151
|
+
| [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. |
|
|
152
|
+
| [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
|
|
153
|
+
| [Notifications](docs/activesupport-notifications.md) | Opt-in `ActiveSupport::Notifications` events for every action call. Emits action class, outcome status, and duration on the `process.action_figure` event. |
|
|
154
|
+
| [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
|
|
155
|
+
| [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
|
|
156
|
+
|
|
157
|
+
## Quick Start
|
|
158
|
+
|
|
159
|
+
**1. Define the action class.**
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# app/actions/users/create_action.rb
|
|
163
|
+
class Users::CreateAction
|
|
164
|
+
include ActionFigure[:jsend]
|
|
165
|
+
|
|
166
|
+
params_schema do
|
|
167
|
+
required(:user).hash do
|
|
168
|
+
required(:name).filled(:string)
|
|
169
|
+
required(:email).filled(:string)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def create(params:, company:)
|
|
174
|
+
user = company.users.create(params[:user])
|
|
175
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
176
|
+
|
|
177
|
+
Created(resource: user.as_json(only: %i[id name email]))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**2. Call it from your controller.**
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
class UsersController < ApplicationController
|
|
186
|
+
def create
|
|
187
|
+
render Users::CreateAction.create(params:, company: current_company)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**3. Test it directly.**
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
class Users::CreateActionTest < Minitest::Test
|
|
196
|
+
include ActionFigure::Testing::Minitest
|
|
197
|
+
|
|
198
|
+
def test_creates_a_user
|
|
199
|
+
company = Company.create!(name: "Acme")
|
|
200
|
+
|
|
201
|
+
result = Users::CreateAction.create(
|
|
202
|
+
params: { user: { name: "Tad", email: "tad@example.com" } },
|
|
203
|
+
company: company
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
assert_Created(result)
|
|
207
|
+
assert_equal "Tad", result[:json][:data]["name"]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def test_fails_when_name_is_missing
|
|
211
|
+
company = Company.create!(name: "Acme")
|
|
212
|
+
|
|
213
|
+
result = Users::CreateAction.create(
|
|
214
|
+
params: { user: { email: "tad@example.com" } },
|
|
215
|
+
company: company
|
|
243
216
|
)
|
|
244
217
|
|
|
245
218
|
assert_UnprocessableContent(result)
|
|
246
|
-
assert_includes result[:json][:
|
|
247
|
-
"gift fields must be provided together or not at all"
|
|
248
|
-
assert_includes result[:json][:errors][:gift_recipient_email],
|
|
249
|
-
"gift fields must be provided together or not at all"
|
|
219
|
+
assert_includes result[:json][:data][:user][:name], "is missing"
|
|
250
220
|
end
|
|
251
221
|
end
|
|
252
222
|
```
|
|
253
223
|
|
|
254
224
|
## Design Philosophy
|
|
255
225
|
|
|
226
|
+
Unlike general-purpose service object libraries, ActionFigure is scoped to controller actions — it validates params, runs your logic, and returns a hash you pass directly to `render`.
|
|
227
|
+
|
|
256
228
|
- **Purpose over convention** — each class does one thing and names it clearly
|
|
257
229
|
- **Explicit over implicit** — no magic method resolution, no inherited callbacks
|
|
258
|
-
- **
|
|
230
|
+
- **Actions own their lifecycle** — validation, execution, and response formatting live together
|
|
259
231
|
- **Controllers become boring** — one-line `render` calls that delegate to action classes
|
|
260
|
-
- **Models and Controllers stay thin** — business logic moves to purpose-built
|
|
232
|
+
- **Models and Controllers stay thin** — business logic moves to purpose-built action classes
|
|
261
233
|
|
|
262
234
|
## Requirements
|
|
263
235
|
|
|
264
236
|
- Ruby >= 3.2
|
|
265
|
-
- [dry-validation](https://dry-rb.org/gems/dry-validation/) ~> 1.10
|
|
237
|
+
- [dry-validation](https://dry-rb.org/gems/dry-validation/) ~> 1.10 — ActionFigure uses dry-validation for schema validation because it's the best tool for the job. There's no dependency injection container, no monads, no functional pipeline. Just a focused layer for controller actions.
|
|
266
238
|
- Rails is not required, but ActionFigure is designed for Rails controller patterns
|
|
267
239
|
|
|
268
240
|
## License
|
data/docs/actions.md
CHANGED
|
@@ -6,9 +6,9 @@ An ActionFigure action class is a single-purpose operation. Each class encapsula
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Naming Your Action Method
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
ActionFigure auto-discovers your action method by name. Define one public instance method on your action class and ActionFigure registers it as the entry point -- no macro required:
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
14
|
class Users::CreateAction
|
|
@@ -21,7 +21,7 @@ class Users::CreateAction
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
24
|
+
def create(params:, company:, **)
|
|
25
25
|
user = company.users.create(params[:user])
|
|
26
26
|
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
27
27
|
|
|
@@ -30,21 +30,31 @@ class Users::CreateAction
|
|
|
30
30
|
end
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Wire it into a controller
|
|
33
|
+
Wire it into a controller using the discovered method name:
|
|
34
34
|
|
|
35
35
|
```ruby
|
|
36
36
|
class UsersController < ApplicationController
|
|
37
37
|
def create
|
|
38
|
-
render Users::CreateAction.
|
|
38
|
+
render Users::CreateAction.create(params:, company: current_company)
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
### How it works
|
|
44
|
+
|
|
45
|
+
ActionFigure uses a `method_added` hook to watch for public instance methods defined on the class. The first public method defined becomes the registered entry point and a matching class-level method is created for it. The full validation pipeline (`params_schema` and `rules`) still runs through the discovered entry point before your method is invoked.
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
### Disambiguation with `entry_point`
|
|
46
48
|
|
|
47
|
-
|
|
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:
|
|
48
58
|
|
|
49
59
|
```ruby
|
|
50
60
|
class Orders::SearchAction
|
|
@@ -66,46 +76,34 @@ class Orders::SearchAction
|
|
|
66
76
|
resource = orders.as_json(only: %i[id tracking_number status])
|
|
67
77
|
Ok(resource:)
|
|
68
78
|
end
|
|
69
|
-
end
|
|
70
|
-
```
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
private
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def index
|
|
77
|
-
render Orders::SearchAction.search(params:, company: current_company)
|
|
82
|
+
def build_scope(company)
|
|
83
|
+
company.orders.active
|
|
78
84
|
end
|
|
79
85
|
end
|
|
80
86
|
```
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
- The instance method must match the declared entry point name (`:search` declares `.search` and expects `#search`). If an entry point is defined, any instance-level `#call` method is ignored by the class-level entry point.
|
|
85
|
-
- The full validation pipeline still runs through the custom entry point -- `params_schema` and `rules` are applied before your method is invoked.
|
|
86
|
-
- Calling `.call` on a class that declares a custom entry point raises a `NoMethodError` with a helpful message:
|
|
87
|
-
|
|
88
|
-
```
|
|
89
|
-
NoMethodError: undefined method 'call' for Orders::SearchAction (use 'search' instead)
|
|
90
|
-
```
|
|
88
|
+
Only one entry point per class is allowed. A second `entry_point` declaration raises an `ArgumentError`:
|
|
91
89
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
ArgumentError: entry_point already defined as 'search' — each action class may declare only one entry point
|
|
96
|
-
```
|
|
90
|
+
```
|
|
91
|
+
ArgumentError: entry_point already defined as 'search' — each action class may declare only one entry point
|
|
92
|
+
```
|
|
97
93
|
|
|
98
94
|
---
|
|
99
95
|
|
|
100
|
-
##
|
|
96
|
+
## Actions Without a Schema
|
|
101
97
|
|
|
102
|
-
Actions that
|
|
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:
|
|
103
101
|
|
|
104
102
|
```ruby
|
|
105
103
|
class HealthCheckAction
|
|
106
104
|
include ActionFigure[:jsend]
|
|
107
105
|
|
|
108
|
-
def
|
|
106
|
+
def check
|
|
109
107
|
Ok(resource: { status: "healthy", time: Time.current })
|
|
110
108
|
end
|
|
111
109
|
end
|
|
@@ -114,17 +112,11 @@ end
|
|
|
114
112
|
```ruby
|
|
115
113
|
class HealthController < ApplicationController
|
|
116
114
|
def show
|
|
117
|
-
render HealthCheckAction.
|
|
115
|
+
render HealthCheckAction.check
|
|
118
116
|
end
|
|
119
117
|
end
|
|
120
118
|
```
|
|
121
119
|
|
|
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
120
|
---
|
|
129
121
|
|
|
130
122
|
## Context Injection
|
|
@@ -142,7 +134,7 @@ class Users::CreateAction
|
|
|
142
134
|
end
|
|
143
135
|
end
|
|
144
136
|
|
|
145
|
-
def
|
|
137
|
+
def create(params:, company:, current_user:)
|
|
146
138
|
user = company.users.create(params[:user].merge(invited_by: current_user))
|
|
147
139
|
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
148
140
|
|
|
@@ -154,7 +146,7 @@ end
|
|
|
154
146
|
```ruby
|
|
155
147
|
class UsersController < ApplicationController
|
|
156
148
|
def create
|
|
157
|
-
render Users::CreateAction.
|
|
149
|
+
render Users::CreateAction.create(
|
|
158
150
|
params:,
|
|
159
151
|
company: current_company,
|
|
160
152
|
current_user: current_user
|
|
@@ -175,7 +167,7 @@ A simple index action needs no params and no schema:
|
|
|
175
167
|
class Users::IndexAction
|
|
176
168
|
include ActionFigure[:jsend]
|
|
177
169
|
|
|
178
|
-
def
|
|
170
|
+
def index(company:)
|
|
179
171
|
users = company.users.order(:name)
|
|
180
172
|
Ok(resource: users.as_json(only: %i[id name email]))
|
|
181
173
|
end
|
|
@@ -185,7 +177,7 @@ end
|
|
|
185
177
|
```ruby
|
|
186
178
|
class UsersController < ApplicationController
|
|
187
179
|
def index
|
|
188
|
-
render Users::IndexAction.
|
|
180
|
+
render Users::IndexAction.index(company: current_company)
|
|
189
181
|
end
|
|
190
182
|
end
|
|
191
183
|
```
|
|
@@ -200,7 +192,7 @@ class Users::ShowAction
|
|
|
200
192
|
required(:id).filled(:integer)
|
|
201
193
|
end
|
|
202
194
|
|
|
203
|
-
def
|
|
195
|
+
def show(params:, company:)
|
|
204
196
|
user = company.users.find_by(id: params[:id])
|
|
205
197
|
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
206
198
|
|
|
@@ -212,7 +204,7 @@ end
|
|
|
212
204
|
```ruby
|
|
213
205
|
class UsersController < ApplicationController
|
|
214
206
|
def show
|
|
215
|
-
render Users::ShowAction.
|
|
207
|
+
render Users::ShowAction.show(params:, company: current_company)
|
|
216
208
|
end
|
|
217
209
|
end
|
|
218
210
|
```
|
|
@@ -230,7 +222,7 @@ class Users::CreateAction
|
|
|
230
222
|
end
|
|
231
223
|
end
|
|
232
224
|
|
|
233
|
-
def
|
|
225
|
+
def create(params:, company:)
|
|
234
226
|
user = company.users.create(params[:user])
|
|
235
227
|
return UnprocessableContent(errors: user.errors.messages) unless user.persisted?
|
|
236
228
|
|
|
@@ -242,7 +234,7 @@ end
|
|
|
242
234
|
```ruby
|
|
243
235
|
class UsersController < ApplicationController
|
|
244
236
|
def create
|
|
245
|
-
render Users::CreateAction.
|
|
237
|
+
render Users::CreateAction.create(params:, company: current_company)
|
|
246
238
|
end
|
|
247
239
|
end
|
|
248
240
|
```
|
|
@@ -261,7 +253,7 @@ class Users::UpdateAction
|
|
|
261
253
|
end
|
|
262
254
|
end
|
|
263
255
|
|
|
264
|
-
def
|
|
256
|
+
def update(params:, company:)
|
|
265
257
|
user = company.users.find_by(id: params[:id])
|
|
266
258
|
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
267
259
|
|
|
@@ -276,7 +268,7 @@ end
|
|
|
276
268
|
```ruby
|
|
277
269
|
class UsersController < ApplicationController
|
|
278
270
|
def update
|
|
279
|
-
render Users::UpdateAction.
|
|
271
|
+
render Users::UpdateAction.update(params:, company: current_company)
|
|
280
272
|
end
|
|
281
273
|
end
|
|
282
274
|
```
|
|
@@ -291,7 +283,7 @@ class Users::DestroyAction
|
|
|
291
283
|
required(:id).filled(:integer)
|
|
292
284
|
end
|
|
293
285
|
|
|
294
|
-
def
|
|
286
|
+
def destroy(params:, company:)
|
|
295
287
|
user = company.users.find_by(id: params[:id])
|
|
296
288
|
return NotFound(errors: { base: ["user not found"] }) unless user
|
|
297
289
|
|
|
@@ -304,7 +296,7 @@ end
|
|
|
304
296
|
```ruby
|
|
305
297
|
class UsersController < ApplicationController
|
|
306
298
|
def destroy
|
|
307
|
-
render Users::DestroyAction.
|
|
299
|
+
render Users::DestroyAction.destroy(params:, company: current_company)
|
|
308
300
|
end
|
|
309
301
|
end
|
|
310
302
|
```
|
|
@@ -325,7 +317,7 @@ class Users::BulkInviteAction
|
|
|
325
317
|
required(:emails).value(:array, min_size?: 1).each(:str?)
|
|
326
318
|
end
|
|
327
319
|
|
|
328
|
-
def
|
|
320
|
+
def invite(params:, company:)
|
|
329
321
|
result = BulkInviteService.call(emails: params[:emails], company: company)
|
|
330
322
|
return UnprocessableContent(errors: result.errors) if result.failures?
|
|
331
323
|
|
|
@@ -337,7 +329,7 @@ end
|
|
|
337
329
|
```ruby
|
|
338
330
|
class Users::InvitesController < ApplicationController
|
|
339
331
|
def create
|
|
340
|
-
render Users::BulkInviteAction.
|
|
332
|
+
render Users::BulkInviteAction.invite(params:, company: current_company)
|
|
341
333
|
end
|
|
342
334
|
end
|
|
343
335
|
```
|
|
@@ -356,7 +348,7 @@ class Reports::GenerateAction
|
|
|
356
348
|
end
|
|
357
349
|
end
|
|
358
350
|
|
|
359
|
-
def
|
|
351
|
+
def generate(params:, current_user:)
|
|
360
352
|
result = ReportService.enqueue(params: params[:report], requested_by: current_user)
|
|
361
353
|
return UnprocessableContent(errors: result.errors) if result.failed?
|
|
362
354
|
|
|
@@ -368,7 +360,7 @@ end
|
|
|
368
360
|
```ruby
|
|
369
361
|
class ReportsController < ApplicationController
|
|
370
362
|
def create
|
|
371
|
-
render Reports::GenerateAction.
|
|
363
|
+
render Reports::GenerateAction.generate(params:, current_user: current_user)
|
|
372
364
|
end
|
|
373
365
|
end
|
|
374
366
|
```
|
|
@@ -379,7 +371,7 @@ File imports work the same way — receive the file, hand it off, translate the
|
|
|
379
371
|
class Products::ImportAction
|
|
380
372
|
include ActionFigure[:jsend]
|
|
381
373
|
|
|
382
|
-
def
|
|
374
|
+
def import(file:, company:)
|
|
383
375
|
result = ProductImportService.call(file: file, company: company)
|
|
384
376
|
return UnprocessableContent(errors: result.errors) if result.failed?
|
|
385
377
|
|
|
@@ -391,7 +383,7 @@ end
|
|
|
391
383
|
```ruby
|
|
392
384
|
class Products::ImportsController < ApplicationController
|
|
393
385
|
def create
|
|
394
|
-
render Products::ImportAction.
|
|
386
|
+
render Products::ImportAction.import(file: params[:file], company: current_company)
|
|
395
387
|
end
|
|
396
388
|
end
|
|
397
389
|
```
|
|
@@ -415,7 +407,7 @@ class Users::CreateAction
|
|
|
415
407
|
end
|
|
416
408
|
end
|
|
417
409
|
|
|
418
|
-
def
|
|
410
|
+
def create(params:)
|
|
419
411
|
user = User.create(params[:user])
|
|
420
412
|
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
421
413
|
|
|
@@ -435,7 +427,7 @@ Users::CreateAction.api_version #=> "2.0"
|
|
|
435
427
|
Inside an action instance, access it through the class:
|
|
436
428
|
|
|
437
429
|
```ruby
|
|
438
|
-
def
|
|
430
|
+
def create(params:, **)
|
|
439
431
|
if self.class.api_version == "2.0"
|
|
440
432
|
# v2 behavior
|
|
441
433
|
end
|