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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +140 -159
- data/docs/actions.md +68 -60
- data/docs/activesupport-notifications.md +12 -10
- data/docs/configuration.md +16 -2
- data/docs/custom-formatters.md +18 -6
- data/docs/response-formatters.md +159 -3
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +28 -10
- data/docs/validation.md +5 -1
- data/lib/action_figure/core.rb +81 -26
- data/lib/action_figure/format_registry.rb +4 -2
- data/lib/action_figure/formatter.rb +3 -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 +4 -6
- data/lib/action_figure/formatters/json_api.rb +21 -8
- 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 +33 -3
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +12 -1
- data/sig/action_figure.rbs +176 -1
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2f1def52c6601cb91ff3b2cd2fc366efeef932c1fba3072932f674cbf1748af
|
|
4
|
+
data.tar.gz: da5c9b0ab274f7e6f1512560af168d4d44c21dc3875ac85c0f79998902fa5df1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ebdc99c40beb7e461eab4022825efd657f15c29fc01a84d432f253928040ad1191057ed7c6b5845670d34af2963e56c9760aec613b590e577d81bae60da2233
|
|
7
|
+
data.tar.gz: d6b1112c59d61637c9e45e8f84b94ef25b75372e899b7f86e88ee0fc66d932325f567913ed75b47eb8283bde0a9d4e67935cbeac751665010b73cd56f1946cb9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to ActionFigure will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.6.2] - 2026-06-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Without a **`params_schema`**, params now pass through **untouched** — **`to_unsafe_h`** is only called when there is a schema to validate against. Previously an **`ActionController::Parameters`** (or any object responding to **`to_unsafe_h`**) was unwrapped into a plain hash even with no schema, contradicting the 0.6.0 "no schema → params pass through unvalidated" contract. **Breaking** for code that relied on the implicit unwrap; such actions should unwrap (e.g. via strong params) themselves.
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
- **`README.md`**: simplify the before/after hint that prompts readers to spot the incorrect render response.
|
|
14
|
+
|
|
15
|
+
## [0.6.1] - 2026-05-23
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`have_action_json`** RSpec matcher (partial match on **`result[:json]`** via **`a_hash_including`**).
|
|
20
|
+
- Notifications payload **`entry_point`** (Symbol) alongside **`action`** for **`process.action_figure`**.
|
|
21
|
+
- **`InitializationNotSupportedError`** when an action class defines **`initialize`** (instances are built with arity-zero **`new`**).
|
|
22
|
+
- CI job running **`bundle exec rbs validate`**.
|
|
23
|
+
- Regression test asserting built-in formatters expose **`Formatter::REQUIRED_METHODS`** (+ **`NoContent`**).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Renamed `IndeterminantEntryPointError` → **`IndeterminateEntryPointError`** (raised when multiple public entry methods exist without `entry_point`). The old constant remains as a deprecated alias (`Module#deprecate_constant`) for one release; update any `rescue` clauses to the new spelling.
|
|
28
|
+
- **`params_schema`** may only be called once per action class; a duplicate call raises **`ArgumentError`** (previously enforced only when a **`rules`** block was already present).
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- Load-order semantics for **`ActionFigure.configure`** (default **`format`** and **`activesupport_notifications`** latch when each class **`include`** runs).
|
|
33
|
+
- Testing guide: matchers/assertions inspect **`[:status]`** only; RSpec **`require`** order.
|
|
34
|
+
- Actions guide: mermaid overview of **`method_added`** / **`entry_point`** discovery.
|
|
35
|
+
- Actions guide: do not define **`initialize`** on actions (arity-zero **`new`**); use **`InitializationNotSupportedError`** when violated.
|
|
36
|
+
- Actions guide: **`api_version`** — clarify global (**`configure`**) vs class macro (**no fallback**).
|
|
37
|
+
- **`README.md`**: `:unprocessable_entity` vs ActionFigure **`result[:status]`** symbol **`:unprocessable_content`**.
|
|
38
|
+
- Configuration guide: **thread safety** / singleton **`ActionFigure.configuration`** semantics.
|
|
39
|
+
- ActiveSupport Notifications: payload documents **`entry_point`**; subscriber examples reference it.
|
|
40
|
+
- Testing guide: **`have_action_json`** RSpec matcher.
|
|
41
|
+
|
|
42
|
+
## [0.6.0] - 2026-03-30
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `Conflict` (409) and `PaymentRequired` (402) response helpers across all formatters
|
|
47
|
+
- Automatic entry point discovery via `method_added` — single public method is detected without needing `entry_point`
|
|
48
|
+
- `IndeterminantEntryPointError` raised when multiple public methods exist without an explicit `entry_point`
|
|
49
|
+
- Status codes documentation (`docs/status-codes.md`)
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- `params_schema` is now optional — actions without a schema pass `params:` through unvalidated
|
|
54
|
+
|
|
55
|
+
## [0.5.0] - 2026-03-25
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- Thread-safe format registry using `Concurrent::Map`
|
|
60
|
+
- RBS type signatures for the full public API (`sig/action_figure.rbs`)
|
|
61
|
+
- Integration test suite (`test/integration/full_pipeline_test.rb`)
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- Schema guard: redefining `params_schema` after `rules` now raises instead of silently dropping rules
|
|
66
|
+
- Keyword argument safety: non-params kwargs pass through untouched
|
|
67
|
+
- Consistent envelope: `Accepted` without a resource uses `nil` data (not omitted key) in Default and Wrapped formatters
|
|
68
|
+
- RSpec negated matcher failure message now shows actual status
|
|
69
|
+
|
|
70
|
+
## [0.4.0] - 2026-03-21
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- Wrapped formatter (`ActionFigure::Formatters::Wrapped`) with uniform `{ data:, errors:, status: }` envelope
|
|
75
|
+
- Default formatter (`ActionFigure::Formatters::Default`) with `{ data: }` envelope
|
|
76
|
+
- `ActiveSupport::Notifications` instrumentation (opt-in via `activesupport_notifications` config)
|
|
77
|
+
- Cross-parameter rule helpers: `exclusive_rule`, `any_rule`, `one_rule`, `all_rule`
|
|
78
|
+
- `meta:` keyword on success response helpers (`Ok`, `Created`, `Accepted`)
|
|
79
|
+
- `.contract` accessor for standalone schema/rules introspection
|
|
80
|
+
- `api_version` class-level macro for per-action version tagging
|
|
81
|
+
- `whiny_extra_params` configuration option
|
|
82
|
+
- Minitest assertions (`assert_Ok`, `assert_Created`, etc.) and RSpec matchers (`be_Ok`, `be_Created`, etc.)
|
|
83
|
+
- JSON:API formatter with `Resource` serializer for ActiveRecord objects
|
|
84
|
+
- Custom formatter registration with load-time validation
|
|
85
|
+
- User-facing documentation for all features
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
|
|
89
|
+
- `UnprocessableEntity` renamed to `UnprocessableContent` to match Rails 7.1+ naming
|
|
90
|
+
|
|
91
|
+
## [0.1.0] - 2026-03-20
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
|
|
95
|
+
- Initial release
|
|
96
|
+
- Core validation pipeline powered by dry-validation
|
|
97
|
+
- JSend response formatter
|
|
98
|
+
- `params_schema` and `rules` DSL
|
|
99
|
+
- `ActionController::Parameters` support via `to_unsafe_h`
|
|
100
|
+
- `NoContent` response helper
|
data/README.md
CHANGED
|
@@ -5,264 +5,245 @@ 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
|
-
> [Full Example](#full-example)<br>
|
|
12
10
|
> [Design Philosophy](#design-philosophy)<br>
|
|
11
|
+
> [Examples](#examples)<br>
|
|
13
12
|
> [Requirements](#requirements)<br>
|
|
14
13
|
> [License](#license)
|
|
15
14
|
---
|
|
16
15
|
|
|
17
|
-
**ActionFigure**
|
|
16
|
+
**ActionFigure** makes your controller actions more usable and understandable. It turns this:
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
```ruby
|
|
19
|
+
class ProjectsController < ApplicationController
|
|
20
|
+
def create
|
|
21
|
+
permitted = params.require(:project).permit(
|
|
22
|
+
:name, :description, settings: [:visibility, :notify_on_mention]
|
|
23
|
+
)
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
if permitted[:name].blank?
|
|
26
|
+
render json: { status: "fail", data: { name: ["is required"] } },
|
|
27
|
+
status: :unprocessable_entity
|
|
28
|
+
return
|
|
29
|
+
end
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
if permitted.dig(:settings, :visibility).present? &&
|
|
32
|
+
!%w[public private].include?(permitted[:settings][:visibility])
|
|
33
|
+
render json: { status: "fail", data: { settings: { visibility: ["must be public or private"] } } },
|
|
34
|
+
status: :unprocessable_entity
|
|
35
|
+
return
|
|
36
|
+
end
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
unless current_user.member_of?(current_workspace)
|
|
39
|
+
render json: { status: "fail", data: { base: ["must be a workspace member"] } },
|
|
40
|
+
status: :forbidden
|
|
41
|
+
return
|
|
42
|
+
end
|
|
28
43
|
|
|
29
|
-
|
|
44
|
+
if current_workspace.projects.exists?(name: permitted[:name])
|
|
45
|
+
render json: { status: "fail", data: { name: ["already exists in this workspace"] } },
|
|
46
|
+
status: :conflict
|
|
47
|
+
return
|
|
48
|
+
end
|
|
30
49
|
|
|
31
|
-
|
|
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
|
-
)
|
|
50
|
+
project = CreateProject.run(permitted, workspace: current_workspace, creator: current_user)
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
if project.errors.any?
|
|
53
|
+
render json: { status: "fail", data: project.errors.messages },
|
|
54
|
+
status: :unprocessable_entity
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
render json: { status: "success", data: ProjectBlueprint.render_as_hash(project) }
|
|
48
59
|
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
company = Company.create!(name: "Acme")
|
|
52
|
-
result = Users::CreateAction.call(
|
|
53
|
-
params: { user: { email: "tad@example.com" } },
|
|
54
|
-
company: company
|
|
55
|
-
)
|
|
63
|
+
into this:
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(result[:json][:data][:user][:name]).to include("is missing")
|
|
62
|
-
expect(User.find_by(email: "tad@example.com")).not_to be_persisted
|
|
65
|
+
```ruby
|
|
66
|
+
class ProjectsController < ApplicationController
|
|
67
|
+
def create
|
|
68
|
+
render Projects::CreateAction.create(params:, current_user:, current_workspace:)
|
|
63
69
|
end
|
|
64
70
|
end
|
|
65
71
|
```
|
|
66
72
|
|
|
67
|
-
**2. Define the action class.**
|
|
68
|
-
|
|
69
73
|
```ruby
|
|
70
|
-
|
|
71
|
-
class Users::CreateAction
|
|
74
|
+
class Projects::CreateAction
|
|
72
75
|
include ActionFigure[:jsend]
|
|
73
76
|
|
|
74
77
|
params_schema do
|
|
75
|
-
required(:
|
|
78
|
+
required(:project).hash do
|
|
76
79
|
required(:name).filled(:string)
|
|
77
|
-
|
|
80
|
+
optional(:description).filled(:string)
|
|
81
|
+
optional(:settings).hash do
|
|
82
|
+
optional(:visibility).filled(:string, included_in?: %w[public private])
|
|
83
|
+
optional(:notify_on_mention).filled(:bool)
|
|
84
|
+
end
|
|
78
85
|
end
|
|
79
86
|
end
|
|
80
87
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
def create(params:, current_user:, current_workspace:)
|
|
89
|
+
unless current_user.member_of?(current_workspace)
|
|
90
|
+
return Forbidden(errors: { base: ["must be a workspace member"] })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if current_workspace.projects.exists?(name: params[:project][:name])
|
|
94
|
+
return Conflict(errors: { name: ["already exists in this workspace"] })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
project = CreateProject.run(params[:project], workspace: current_workspace, creator: current_user)
|
|
98
|
+
return UnprocessableContent(errors: project.errors.messages) if project.errors.any?
|
|
84
99
|
|
|
85
|
-
Created(resource:
|
|
100
|
+
Created(resource: ProjectBlueprint.render_as_hash(project))
|
|
86
101
|
end
|
|
87
102
|
end
|
|
88
103
|
```
|
|
89
104
|
|
|
90
|
-
|
|
105
|
+
- The shape and types of your params are obvious
|
|
106
|
+
- The structure is clear
|
|
107
|
+
- The tests are easy (and 10x faster)
|
|
108
|
+
- The responses are uniform and render-ready
|
|
109
|
+
|
|
110
|
+
(Look closely, one of the responses in the first example is wrong. Can you spot it?)
|
|
111
|
+
|
|
112
|
+
## Installation
|
|
113
|
+
|
|
114
|
+
Add to your Gemfile and `bundle install`:
|
|
91
115
|
|
|
92
116
|
```ruby
|
|
93
|
-
|
|
94
|
-
def create
|
|
95
|
-
render Users::CreateAction.call(params:, company: current_company)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
117
|
+
gem "action_figure"
|
|
98
118
|
```
|
|
99
119
|
|
|
100
120
|
## How It Works
|
|
101
121
|
|
|
102
122
|
Every action class has three responsibilities:
|
|
103
123
|
|
|
104
|
-
1. **Check params** — `params_schema` validates structure and types
|
|
105
|
-
2. **Orchestrate** —
|
|
124
|
+
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.
|
|
125
|
+
2. **Orchestrate** — your action method coordinates the work: creating records, coordinating collaborators, enqueuing jobs, or anything else the action requires. The action is the entry point, not necessarily where all the logic lives.
|
|
106
126
|
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
127
|
|
|
108
128
|
## Features
|
|
109
129
|
|
|
110
130
|
| Feature | Description |
|
|
111
131
|
|---------|-------------|
|
|
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 `
|
|
132
|
+
| [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`. |
|
|
113
133
|
| [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. |
|
|
134
|
+
| [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). |
|
|
114
135
|
| [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) |
|
|
136
|
+
| [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. |
|
|
116
137
|
| [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
|
|
117
138
|
| [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
139
|
| [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
|
|
119
140
|
| [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
|
|
120
141
|
|
|
121
|
-
##
|
|
142
|
+
## Design Philosophy
|
|
143
|
+
|
|
144
|
+
ActionFigure is scoped to controller actions — it validates params, runs your logic, and returns a hash you pass directly to `render`.
|
|
145
|
+
|
|
146
|
+
- **Purpose over convention** — each class does one thing and names it clearly
|
|
147
|
+
- **Explicit over implicit** — no magic method resolution, no inherited callbacks
|
|
148
|
+
- **Actions own their lifecycle** — validation, execution, and response formatting live together
|
|
149
|
+
- **Controllers become boring** — one-line `render` calls that delegate to action classes
|
|
150
|
+
- **Separate domain tests from perimeter tests** — keep your controller tests for perimeter checks, but now your domain logic lives in plain method calls. Faster tests, clearer failures.
|
|
122
151
|
|
|
123
|
-
|
|
152
|
+
## Examples
|
|
124
153
|
|
|
125
|
-
|
|
154
|
+
### Validation Rules
|
|
155
|
+
|
|
156
|
+
Cross-parameter helpers make multi-field constraints declarative:
|
|
126
157
|
|
|
127
158
|
```ruby
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
include ActionFigure[:wrapped]
|
|
159
|
+
class Search::LookupAction
|
|
160
|
+
include ActionFigure[:jsend]
|
|
131
161
|
|
|
132
162
|
params_schema do
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
optional(:coupon_code).filled(:string)
|
|
136
|
-
optional(:gift_message).filled(:string)
|
|
137
|
-
optional(:gift_recipient_email).filled(:string)
|
|
163
|
+
optional(:user_id).filled(:integer)
|
|
164
|
+
optional(:email).filled(:string)
|
|
138
165
|
end
|
|
139
166
|
|
|
140
167
|
rules do
|
|
141
|
-
|
|
142
|
-
"gift fields must be provided together or not at all")
|
|
168
|
+
exclusive_rule(:user_id, :email, "provide one, not both")
|
|
143
169
|
end
|
|
144
170
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
return Forbidden(errors: { base: ["unpaid balance on account"] })
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
item = Item.find_by(id: params[:item_id])
|
|
151
|
-
return NotFound(errors: { item_id: ["item not found"] }) unless item
|
|
171
|
+
def lookup(params:)
|
|
172
|
+
user = params[:user_id] ? User.find_by(id: params[:user_id]) : User.find_by(email: params[:email])
|
|
152
173
|
|
|
153
|
-
|
|
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:)
|
|
174
|
+
Ok(resource: user.as_json)
|
|
162
175
|
end
|
|
163
176
|
end
|
|
164
177
|
```
|
|
165
178
|
|
|
166
|
-
|
|
179
|
+
### Response Formatters
|
|
167
180
|
|
|
168
|
-
|
|
169
|
-
class OrdersController < ApplicationController
|
|
170
|
-
def create
|
|
171
|
-
render Orders::CreateAction.call(params:, current_user:)
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
**Testing it:**
|
|
181
|
+
Choose a response envelope by name. The same helpers return different shapes:
|
|
177
182
|
|
|
178
183
|
```ruby
|
|
179
|
-
#
|
|
180
|
-
|
|
184
|
+
# Default
|
|
185
|
+
Created(resource: user)
|
|
186
|
+
# => { json: { data: user }, status: :created }
|
|
181
187
|
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
)
|
|
188
|
+
# JSend
|
|
189
|
+
Created(resource: user)
|
|
190
|
+
# => { json: { status: "success", data: user }, status: :created }
|
|
193
191
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
end
|
|
192
|
+
# JSON:API
|
|
193
|
+
Created(resource: user)
|
|
194
|
+
# => { json: { data: { type: "users", id: "1", attributes: user } }, status: :created }
|
|
198
195
|
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
# Wrapped
|
|
197
|
+
Created(resource: user)
|
|
198
|
+
# => { json: { data: user, errors: nil, status: "success" }, status: :created }
|
|
199
|
+
```
|
|
201
200
|
|
|
202
|
-
|
|
203
|
-
params: { item_id: 1, quantity: 1 },
|
|
204
|
-
current_user: user
|
|
205
|
-
)
|
|
201
|
+
### Testing
|
|
206
202
|
|
|
207
|
-
|
|
208
|
-
assert_includes result[:json][:errors][:base], "unpaid balance on account"
|
|
209
|
-
end
|
|
203
|
+
Action classes are plain method calls — no request setup needed:
|
|
210
204
|
|
|
211
|
-
|
|
212
|
-
|
|
205
|
+
```ruby
|
|
206
|
+
class Search::LookupActionTest < Minitest::Test
|
|
207
|
+
include ActionFigure::Testing::Minitest
|
|
213
208
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
209
|
+
def test_finds_by_email
|
|
210
|
+
result = Search::LookupAction.lookup(
|
|
211
|
+
params: { email: "tad@example.com" }
|
|
217
212
|
)
|
|
218
213
|
|
|
219
|
-
|
|
220
|
-
assert_includes result[:json][:errors][:item_id], "item not found"
|
|
214
|
+
assert_Ok(result)
|
|
221
215
|
end
|
|
222
216
|
|
|
223
|
-
def
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
result = Orders::CreateAction.call(
|
|
228
|
-
params: { item_id: item.id, quantity: 5 },
|
|
229
|
-
current_user: user
|
|
217
|
+
def test_rejects_both_user_id_and_email
|
|
218
|
+
result = Search::LookupAction.lookup(
|
|
219
|
+
params: { user_id: 1, email: "tad@example.com" }
|
|
230
220
|
)
|
|
231
221
|
|
|
232
222
|
assert_UnprocessableContent(result)
|
|
233
|
-
assert_includes result[:json][:
|
|
223
|
+
assert_includes result[:json][:data][:user_id], "provide one, not both"
|
|
234
224
|
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
235
227
|
|
|
236
|
-
|
|
237
|
-
user = User.create!(name: "Tad")
|
|
238
|
-
item = Item.create!(name: "Widget", price: 29.00)
|
|
228
|
+
### Actions Without a Schema
|
|
239
229
|
|
|
240
|
-
|
|
241
|
-
params: { item_id: item.id, quantity: 1, gift_message: "Enjoy!" },
|
|
242
|
-
current_user: user
|
|
243
|
-
)
|
|
230
|
+
Not every action needs parameter validation:
|
|
244
231
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
232
|
+
```ruby
|
|
233
|
+
class HealthCheckAction
|
|
234
|
+
# Uses the globally-configured format
|
|
235
|
+
include ActionFigure
|
|
236
|
+
|
|
237
|
+
def check(current_user:)
|
|
238
|
+
Ok(resource: { status: "healthy", user: current_user.name })
|
|
250
239
|
end
|
|
251
240
|
end
|
|
252
241
|
```
|
|
253
242
|
|
|
254
|
-
## Design Philosophy
|
|
255
|
-
|
|
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
|
|
261
|
-
|
|
262
243
|
## Requirements
|
|
263
244
|
|
|
264
245
|
- Ruby >= 3.2
|
|
265
|
-
- [dry-validation](https://dry-rb.org/gems/dry-validation/) ~> 1.10
|
|
246
|
+
- [dry-validation](https://dry-rb.org/gems/dry-validation/) ~> 1.10 — ActionFigure uses dry-validation for schema validation. However, there's no dependency injection container, monads, or functional pipeline. Just a focused layer for controller actions.
|
|
266
247
|
- Rails is not required, but ActionFigure is designed for Rails controller patterns
|
|
267
248
|
|
|
268
249
|
## License
|