action_figure 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE.txt +1 -1
- data/README.md +247 -16
- data/docs/actions.md +503 -0
- data/docs/activesupport-notifications.md +113 -0
- data/docs/configuration.md +88 -0
- data/docs/custom-formatters.md +175 -0
- data/docs/integration-patterns.md +331 -0
- data/docs/response-formatters.md +932 -0
- data/docs/testing.md +270 -0
- data/docs/validation.md +294 -0
- data/lib/action_figure/configuration.rb +33 -0
- data/lib/action_figure/core.rb +180 -0
- data/lib/action_figure/format_registry.rb +38 -0
- data/lib/action_figure/formatter.rb +14 -0
- data/lib/action_figure/formatters/default.rb +39 -0
- data/lib/action_figure/formatters/jsend.rb +41 -0
- data/lib/action_figure/formatters/json_api/resource.rb +32 -0
- data/lib/action_figure/formatters/json_api.rb +57 -0
- data/lib/action_figure/formatters/wrapped.rb +41 -0
- data/lib/action_figure/testing/minitest.rb +50 -0
- data/lib/action_figure/testing/rspec.rb +42 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +67 -0
- metadata +25 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2afc60d26a2b7f19aa7a5e8e1e438dba5f0400688c9dd0b5ec184f0307484366
|
|
4
|
+
data.tar.gz: 346713da33afd14d6ea9d7862fbd7e71030d116e467664d6c73f19397e9a54c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2dba3a5b85d8f8bd55ebcfca08fb9eeedb508e2ff2d5f16cd8c971c0e3eb5586ab614922b341c90aaed4ad4e3607eefd71c7bd76e881592b45f5a1ac4688e9f8
|
|
7
|
+
data.tar.gz: 9a796394dc06f986ec4cbeef0e7e9cf3e3a9a80aaa15b51edb16201e89db815e86f8ccc3b816366543c7a8878327fd8f2be9fee11a4fbfe4adc47fade36e3879
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,38 +1,269 @@
|
|
|
1
1
|
# ActionFigure
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fully-articulated controller actions.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
---
|
|
6
|
+
> #### Table of Contents
|
|
7
|
+
> [Installation](#installation)<br>
|
|
8
|
+
> [Quick Start](#quick-start)<br>
|
|
9
|
+
> [How It Works](#how-it-works)<br>
|
|
10
|
+
> [Features](#features)<br>
|
|
11
|
+
> [Full Example](#full-example)<br>
|
|
12
|
+
> [Design Philosophy](#design-philosophy)<br>
|
|
13
|
+
> [Requirements](#requirements)<br>
|
|
14
|
+
> [License](#license)
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
**ActionFigure** replaces gnarly controller method logic with explicit, purpose-driven operation classes. Each action validates its input, executes its logic, and returns a render-ready hash — making your controller action methods one-liners and behavior easily testable.
|
|
6
18
|
|
|
7
19
|
## Installation
|
|
8
20
|
|
|
9
|
-
|
|
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
|
+
)
|
|
10
42
|
|
|
11
|
-
|
|
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
|
|
12
49
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
15
65
|
```
|
|
16
66
|
|
|
17
|
-
|
|
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
|
|
18
80
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
21
88
|
```
|
|
22
89
|
|
|
23
|
-
|
|
90
|
+
**3. Call it from your controller.**
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class UsersController < ApplicationController
|
|
94
|
+
def create
|
|
95
|
+
render Users::CreateAction.call(params:, company: current_company)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## How It Works
|
|
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:**
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# app/actions/orders/create_action.rb
|
|
129
|
+
class Orders::CreateAction
|
|
130
|
+
include ActionFigure[:wrapped]
|
|
131
|
+
|
|
132
|
+
params_schema do
|
|
133
|
+
required(:item_id).filled(:integer)
|
|
134
|
+
required(:quantity).filled(:integer)
|
|
135
|
+
optional(:coupon_code).filled(:string)
|
|
136
|
+
optional(:gift_message).filled(:string)
|
|
137
|
+
optional(:gift_recipient_email).filled(:string)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
rules do
|
|
141
|
+
all_rule(:gift_message, :gift_recipient_email,
|
|
142
|
+
"gift fields must be provided together or not at all")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def call(params:, current_user:)
|
|
146
|
+
if current_user.unpaid_balance?
|
|
147
|
+
return Forbidden(errors: { base: ["unpaid balance on account"] })
|
|
148
|
+
end
|
|
24
149
|
|
|
25
|
-
|
|
150
|
+
item = Item.find_by(id: params[:item_id])
|
|
151
|
+
return NotFound(errors: { item_id: ["item not found"] }) unless item
|
|
26
152
|
|
|
27
|
-
|
|
153
|
+
order = current_user.orders.create(
|
|
154
|
+
item: item,
|
|
155
|
+
quantity: params[:quantity],
|
|
156
|
+
coupon_code: params[:coupon_code]
|
|
157
|
+
)
|
|
158
|
+
return UnprocessableContent(errors: order.errors.messages) if order.errors.any?
|
|
159
|
+
|
|
160
|
+
resource = OrderBlueprint.render_as_hash(order, view: :confirmation)
|
|
161
|
+
Created(resource:)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**The controller:**
|
|
167
|
+
|
|
168
|
+
```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
|
+
class Orders::CreateActionTest < Minitest::Test
|
|
183
|
+
include ActionFigure::Testing::Minitest
|
|
184
|
+
|
|
185
|
+
def test_creates_an_order
|
|
186
|
+
user = User.create!(name: "Tad")
|
|
187
|
+
item = Item.create!(name: "Widget", price: 29.00)
|
|
188
|
+
|
|
189
|
+
result = Orders::CreateAction.call(
|
|
190
|
+
params: { item_id: item.id, quantity: 2 },
|
|
191
|
+
current_user: user
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
assert_Created(result)
|
|
195
|
+
assert_equal item.id, result[:json][:data]["item_id"]
|
|
196
|
+
assert_equal 2, result[:json][:data]["quantity"]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_forbidden_with_unpaid_balance
|
|
200
|
+
user = User.create!(name: "Tud", balance: -1)
|
|
201
|
+
|
|
202
|
+
result = Orders::CreateAction.call(
|
|
203
|
+
params: { item_id: 1, quantity: 1 },
|
|
204
|
+
current_user: user
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
assert_Forbidden(result)
|
|
208
|
+
assert_includes result[:json][:errors][:base], "unpaid balance on account"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def test_not_found_when_item_missing
|
|
212
|
+
user = User.create!(name: "Tad")
|
|
213
|
+
|
|
214
|
+
result = Orders::CreateAction.call(
|
|
215
|
+
params: { item_id: 999, quantity: 1 },
|
|
216
|
+
current_user: user
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
assert_NotFound(result)
|
|
220
|
+
assert_includes result[:json][:errors][:item_id], "item not found"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def test_surfaces_model_validation_errors
|
|
224
|
+
user = User.create!(name: "Tad")
|
|
225
|
+
item = Item.create!(name: "Widget", price: 29.00, stock: 0)
|
|
226
|
+
|
|
227
|
+
result = Orders::CreateAction.call(
|
|
228
|
+
params: { item_id: item.id, quantity: 5 },
|
|
229
|
+
current_user: user
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
assert_UnprocessableContent(result)
|
|
233
|
+
assert_includes result[:json][:errors][:quantity], "exceeds available stock"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def test_rejects_partial_gift_fields
|
|
237
|
+
user = User.create!(name: "Tad")
|
|
238
|
+
item = Item.create!(name: "Widget", price: 29.00)
|
|
239
|
+
|
|
240
|
+
result = Orders::CreateAction.call(
|
|
241
|
+
params: { item_id: item.id, quantity: 1, gift_message: "Enjoy!" },
|
|
242
|
+
current_user: user
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assert_UnprocessableContent(result)
|
|
246
|
+
assert_includes result[:json][:errors][:gift_message],
|
|
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"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
28
253
|
|
|
29
|
-
|
|
254
|
+
## Design Philosophy
|
|
30
255
|
|
|
31
|
-
|
|
256
|
+
- **Purpose over convention** — each class does one thing and names it clearly
|
|
257
|
+
- **Explicit over implicit** — no magic method resolution, no inherited callbacks
|
|
258
|
+
- **Operations own their lifecycle** — validation, execution, and response formatting live together
|
|
259
|
+
- **Controllers become boring** — one-line `render` calls that delegate to action classes
|
|
260
|
+
- **Models and Controllers stay thin** — business logic moves to purpose-built operations
|
|
32
261
|
|
|
33
|
-
##
|
|
262
|
+
## Requirements
|
|
34
263
|
|
|
35
|
-
|
|
264
|
+
- Ruby >= 3.2
|
|
265
|
+
- [dry-validation](https://dry-rb.org/gems/dry-validation/) ~> 1.10
|
|
266
|
+
- Rails is not required, but ActionFigure is designed for Rails controller patterns
|
|
36
267
|
|
|
37
268
|
## License
|
|
38
269
|
|