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.
- checksums.yaml +4 -4
- data/LICENSE.txt +1 -1
- data/README.md +219 -16
- data/docs/actions.md +495 -0
- data/docs/activesupport-notifications.md +113 -0
- data/docs/configuration.md +88 -0
- data/docs/custom-formatters.md +185 -0
- data/docs/integration-patterns.md +331 -0
- data/docs/response-formatters.md +1088 -0
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +272 -0
- data/docs/validation.md +294 -0
- data/lib/action_figure/configuration.rb +33 -0
- data/lib/action_figure/core.rb +200 -0
- data/lib/action_figure/format_registry.rb +40 -0
- data/lib/action_figure/formatter.rb +14 -0
- data/lib/action_figure/formatters/default.rb +49 -0
- data/lib/action_figure/formatters/jsend.rb +49 -0
- data/lib/action_figure/formatters/json_api/resource.rb +30 -0
- data/lib/action_figure/formatters/json_api.rb +65 -0
- data/lib/action_figure/formatters/wrapped.rb +49 -0
- data/lib/action_figure/testing/minitest.rb +58 -0
- data/lib/action_figure/testing/rspec.rb +44 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +68 -0
- data/sig/action_figure.rbs +157 -1
- metadata +26 -5
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/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,38 +1,241 @@
|
|
|
1
1
|
# ActionFigure
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fully-articulated controller actions.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
> #### Table of Contents
|
|
7
|
+
> [Installation](#installation)<br>
|
|
8
|
+
> [How It Works](#how-it-works)<br>
|
|
9
|
+
> [Features](#features)<br>
|
|
10
|
+
> [Quick Start](#quick-start)<br>
|
|
11
|
+
> [Design Philosophy](#design-philosophy)<br>
|
|
12
|
+
> [Requirements](#requirements)<br>
|
|
13
|
+
> [License](#license)
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
**ActionFigure** extracts controller actions into classes that validate params, orchestrate work, and return render-ready responses. Your controller becomes:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
class OrdersController < ApplicationController
|
|
20
|
+
def create
|
|
21
|
+
render Orders::CreateAction.create(params:, current_user:)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The action class owns everything that used to be scattered across the controller method, strong params, model callbacks, and ad-hoc response building:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
class Orders::CreateAction
|
|
30
|
+
include ActionFigure[:wrapped]
|
|
31
|
+
|
|
32
|
+
params_schema do
|
|
33
|
+
required(:item_id).filled(:integer)
|
|
34
|
+
required(:quantity).filled(:integer)
|
|
35
|
+
optional(:coupon_code).filled(:string)
|
|
36
|
+
optional(:gift_message).filled(:string)
|
|
37
|
+
optional(:gift_recipient_email).filled(:string)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
rules do
|
|
41
|
+
all_rule(:gift_message, :gift_recipient_email,
|
|
42
|
+
"gift fields must be provided together or not at all")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create(params:, current_user:)
|
|
46
|
+
if current_user.unpaid_balance?
|
|
47
|
+
return Forbidden(errors: { base: ["unpaid balance on account"] })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
item = Item.find_by(id: params[:item_id])
|
|
51
|
+
return NotFound(errors: { item_id: ["item not found"] }) unless item
|
|
52
|
+
|
|
53
|
+
order = current_user.orders.create(
|
|
54
|
+
item: item,
|
|
55
|
+
quantity: params[:quantity],
|
|
56
|
+
coupon_code: params[:coupon_code]
|
|
57
|
+
)
|
|
58
|
+
return UnprocessableContent(errors: order.errors.messages) if order.errors.any?
|
|
59
|
+
|
|
60
|
+
resource = OrderBlueprint.render_as_hash(order, view: :confirmation)
|
|
61
|
+
Created(resource:)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Param validation, cross-field rules, authorization, error handling, and response formatting — all in one place, all testable without a request:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class Orders::CreateActionTest < Minitest::Test
|
|
70
|
+
include ActionFigure::Testing::Minitest
|
|
71
|
+
|
|
72
|
+
def test_creates_an_order
|
|
73
|
+
user = User.create!(name: "Tad")
|
|
74
|
+
item = Item.create!(name: "Widget", price: 29.00)
|
|
75
|
+
|
|
76
|
+
result = Orders::CreateAction.create(
|
|
77
|
+
params: { item_id: item.id, quantity: 2 },
|
|
78
|
+
current_user: user
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert_Created(result)
|
|
82
|
+
assert_equal item.id, result[:json][:data]["item_id"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_forbidden_with_unpaid_balance
|
|
86
|
+
user = User.create!(name: "Tad", balance: -1)
|
|
87
|
+
|
|
88
|
+
result = Orders::CreateAction.create(
|
|
89
|
+
params: { item_id: 1, quantity: 1 },
|
|
90
|
+
current_user: user
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert_Forbidden(result)
|
|
94
|
+
assert_includes result[:json][:errors][:base], "unpaid balance on account"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_not_found_when_item_missing
|
|
98
|
+
user = User.create!(name: "Tad")
|
|
99
|
+
|
|
100
|
+
result = Orders::CreateAction.create(
|
|
101
|
+
params: { item_id: 999, quantity: 1 },
|
|
102
|
+
current_user: user
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assert_NotFound(result)
|
|
106
|
+
assert_includes result[:json][:errors][:item_id], "item not found"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_rejects_partial_gift_fields
|
|
110
|
+
user = User.create!(name: "Tad")
|
|
111
|
+
item = Item.create!(name: "Widget", price: 29.00)
|
|
112
|
+
|
|
113
|
+
result = Orders::CreateAction.create(
|
|
114
|
+
params: { item_id: item.id, quantity: 1, gift_message: "Enjoy!" },
|
|
115
|
+
current_user: user
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert_UnprocessableContent(result)
|
|
119
|
+
assert_includes result[:json][:errors][:gift_message],
|
|
120
|
+
"gift fields must be provided together or not at all"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
4
124
|
|
|
5
|
-
|
|
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.
|
|
6
126
|
|
|
7
127
|
## Installation
|
|
8
128
|
|
|
9
|
-
|
|
129
|
+
Add to your Gemfile and `bundle install`:
|
|
10
130
|
|
|
11
|
-
|
|
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]
|
|
12
165
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
15
180
|
```
|
|
16
181
|
|
|
17
|
-
|
|
182
|
+
**2. Call it from your controller.**
|
|
18
183
|
|
|
19
|
-
```
|
|
20
|
-
|
|
184
|
+
```ruby
|
|
185
|
+
class UsersController < ApplicationController
|
|
186
|
+
def create
|
|
187
|
+
render Users::CreateAction.create(params:, company: current_company)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
21
190
|
```
|
|
22
191
|
|
|
23
|
-
|
|
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
|
+
)
|
|
24
205
|
|
|
25
|
-
|
|
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
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert_UnprocessableContent(result)
|
|
219
|
+
assert_includes result[:json][:data][:user][:name], "is missing"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
26
223
|
|
|
27
|
-
##
|
|
224
|
+
## Design Philosophy
|
|
28
225
|
|
|
29
|
-
|
|
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`.
|
|
30
227
|
|
|
31
|
-
|
|
228
|
+
- **Purpose over convention** — each class does one thing and names it clearly
|
|
229
|
+
- **Explicit over implicit** — no magic method resolution, no inherited callbacks
|
|
230
|
+
- **Actions own their lifecycle** — validation, execution, and response formatting live together
|
|
231
|
+
- **Controllers become boring** — one-line `render` calls that delegate to action classes
|
|
232
|
+
- **Models and Controllers stay thin** — business logic moves to purpose-built action classes
|
|
32
233
|
|
|
33
|
-
##
|
|
234
|
+
## Requirements
|
|
34
235
|
|
|
35
|
-
|
|
236
|
+
- Ruby >= 3.2
|
|
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.
|
|
238
|
+
- Rails is not required, but ActionFigure is designed for Rails controller patterns
|
|
36
239
|
|
|
37
240
|
## License
|
|
38
241
|
|