action_figure 0.6.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 +135 -126
- data/docs/actions.md +19 -3
- data/docs/activesupport-notifications.md +12 -10
- data/docs/configuration.md +16 -2
- data/docs/custom-formatters.md +4 -2
- data/docs/testing.md +16 -0
- data/docs/validation.md +4 -0
- data/lib/action_figure/core.rb +45 -10
- data/lib/action_figure/formatter.rb +2 -0
- data/lib/action_figure/formatters/json_api/resource.rb +1 -1
- data/lib/action_figure/formatters/json_api.rb +13 -8
- data/lib/action_figure/testing/rspec.rb +28 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +11 -1
- data/sig/action_figure.rbs +21 -2
- metadata +18 -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
|
@@ -7,122 +7,107 @@ Fully-articulated controller actions.
|
|
|
7
7
|
> [Installation](#installation)<br>
|
|
8
8
|
> [How It Works](#how-it-works)<br>
|
|
9
9
|
> [Features](#features)<br>
|
|
10
|
-
> [Quick Start](#quick-start)<br>
|
|
11
10
|
> [Design Philosophy](#design-philosophy)<br>
|
|
11
|
+
> [Examples](#examples)<br>
|
|
12
12
|
> [Requirements](#requirements)<br>
|
|
13
13
|
> [License](#license)
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
**ActionFigure**
|
|
16
|
+
**ActionFigure** makes your controller actions more usable and understandable. It turns this:
|
|
17
17
|
|
|
18
18
|
```ruby
|
|
19
|
-
class
|
|
19
|
+
class ProjectsController < ApplicationController
|
|
20
20
|
def create
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
21
|
+
permitted = params.require(:project).permit(
|
|
22
|
+
:name, :description, settings: [:visibility, :notify_on_mention]
|
|
23
|
+
)
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
if permitted[:name].blank?
|
|
26
|
+
render json: { status: "fail", data: { name: ["is required"] } },
|
|
27
|
+
status: :unprocessable_entity
|
|
28
|
+
return
|
|
29
|
+
end
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
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
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
48
|
end
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
return NotFound(errors: { item_id: ["item not found"] }) unless item
|
|
50
|
+
project = CreateProject.run(permitted, workspace: current_workspace, creator: current_user)
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return UnprocessableContent(errors: order.errors.messages) if order.errors.any?
|
|
52
|
+
if project.errors.any?
|
|
53
|
+
render json: { status: "fail", data: project.errors.messages },
|
|
54
|
+
status: :unprocessable_entity
|
|
55
|
+
return
|
|
56
|
+
end
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
Created(resource:)
|
|
58
|
+
render json: { status: "success", data: ProjectBlueprint.render_as_hash(project) }
|
|
62
59
|
end
|
|
63
60
|
end
|
|
64
61
|
```
|
|
65
62
|
|
|
66
|
-
|
|
63
|
+
into this:
|
|
67
64
|
|
|
68
65
|
```ruby
|
|
69
|
-
class
|
|
70
|
-
|
|
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"]
|
|
66
|
+
class ProjectsController < ApplicationController
|
|
67
|
+
def create
|
|
68
|
+
render Projects::CreateAction.create(params:, current_user:, current_workspace:)
|
|
83
69
|
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
result = Orders::CreateAction.create(
|
|
89
|
-
params: { item_id: 1, quantity: 1 },
|
|
90
|
-
current_user: user
|
|
91
|
-
)
|
|
73
|
+
```ruby
|
|
74
|
+
class Projects::CreateAction
|
|
75
|
+
include ActionFigure[:jsend]
|
|
92
76
|
|
|
93
|
-
|
|
94
|
-
|
|
77
|
+
params_schema do
|
|
78
|
+
required(:project).hash do
|
|
79
|
+
required(:name).filled(:string)
|
|
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
|
|
85
|
+
end
|
|
95
86
|
end
|
|
96
87
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
|
108
92
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
93
|
+
if current_workspace.projects.exists?(name: params[:project][:name])
|
|
94
|
+
return Conflict(errors: { name: ["already exists in this workspace"] })
|
|
95
|
+
end
|
|
112
96
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
current_user: user
|
|
116
|
-
)
|
|
97
|
+
project = CreateProject.run(params[:project], workspace: current_workspace, creator: current_user)
|
|
98
|
+
return UnprocessableContent(errors: project.errors.messages) if project.errors.any?
|
|
117
99
|
|
|
118
|
-
|
|
119
|
-
assert_includes result[:json][:errors][:gift_message],
|
|
120
|
-
"gift fields must be provided together or not at all"
|
|
100
|
+
Created(resource: ProjectBlueprint.render_as_hash(project))
|
|
121
101
|
end
|
|
122
102
|
end
|
|
123
103
|
```
|
|
124
104
|
|
|
125
|
-
|
|
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?)
|
|
126
111
|
|
|
127
112
|
## Installation
|
|
128
113
|
|
|
@@ -137,7 +122,7 @@ gem "action_figure"
|
|
|
137
122
|
Every action class has three responsibilities:
|
|
138
123
|
|
|
139
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.
|
|
140
|
-
2. **Orchestrate** — your action method coordinates the work: creating records,
|
|
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.
|
|
141
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.
|
|
142
127
|
|
|
143
128
|
## Features
|
|
@@ -154,87 +139,111 @@ Every action class has three responsibilities:
|
|
|
154
139
|
| [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
|
|
155
140
|
| [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
|
|
156
141
|
|
|
157
|
-
##
|
|
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.
|
|
151
|
+
|
|
152
|
+
## Examples
|
|
153
|
+
|
|
154
|
+
### Validation Rules
|
|
158
155
|
|
|
159
|
-
|
|
156
|
+
Cross-parameter helpers make multi-field constraints declarative:
|
|
160
157
|
|
|
161
158
|
```ruby
|
|
162
|
-
|
|
163
|
-
class Users::CreateAction
|
|
159
|
+
class Search::LookupAction
|
|
164
160
|
include ActionFigure[:jsend]
|
|
165
161
|
|
|
166
162
|
params_schema do
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
required(:email).filled(:string)
|
|
170
|
-
end
|
|
163
|
+
optional(:user_id).filled(:integer)
|
|
164
|
+
optional(:email).filled(:string)
|
|
171
165
|
end
|
|
172
166
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
167
|
+
rules do
|
|
168
|
+
exclusive_rule(:user_id, :email, "provide one, not both")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def lookup(params:)
|
|
172
|
+
user = params[:user_id] ? User.find_by(id: params[:user_id]) : User.find_by(email: params[:email])
|
|
176
173
|
|
|
177
|
-
|
|
174
|
+
Ok(resource: user.as_json)
|
|
178
175
|
end
|
|
179
176
|
end
|
|
180
177
|
```
|
|
181
178
|
|
|
182
|
-
|
|
179
|
+
### Response Formatters
|
|
180
|
+
|
|
181
|
+
Choose a response envelope by name. The same helpers return different shapes:
|
|
183
182
|
|
|
184
183
|
```ruby
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
# Default
|
|
185
|
+
Created(resource: user)
|
|
186
|
+
# => { json: { data: user }, status: :created }
|
|
187
|
+
|
|
188
|
+
# JSend
|
|
189
|
+
Created(resource: user)
|
|
190
|
+
# => { json: { status: "success", data: user }, status: :created }
|
|
191
|
+
|
|
192
|
+
# JSON:API
|
|
193
|
+
Created(resource: user)
|
|
194
|
+
# => { json: { data: { type: "users", id: "1", attributes: user } }, status: :created }
|
|
195
|
+
|
|
196
|
+
# Wrapped
|
|
197
|
+
Created(resource: user)
|
|
198
|
+
# => { json: { data: user, errors: nil, status: "success" }, status: :created }
|
|
190
199
|
```
|
|
191
200
|
|
|
192
|
-
|
|
201
|
+
### Testing
|
|
202
|
+
|
|
203
|
+
Action classes are plain method calls — no request setup needed:
|
|
193
204
|
|
|
194
205
|
```ruby
|
|
195
|
-
class
|
|
206
|
+
class Search::LookupActionTest < Minitest::Test
|
|
196
207
|
include ActionFigure::Testing::Minitest
|
|
197
208
|
|
|
198
|
-
def
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
result = Users::CreateAction.create(
|
|
202
|
-
params: { user: { name: "Tad", email: "tad@example.com" } },
|
|
203
|
-
company: company
|
|
209
|
+
def test_finds_by_email
|
|
210
|
+
result = Search::LookupAction.lookup(
|
|
211
|
+
params: { email: "tad@example.com" }
|
|
204
212
|
)
|
|
205
213
|
|
|
206
|
-
|
|
207
|
-
assert_equal "Tad", result[:json][:data]["name"]
|
|
214
|
+
assert_Ok(result)
|
|
208
215
|
end
|
|
209
216
|
|
|
210
|
-
def
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
result = Users::CreateAction.create(
|
|
214
|
-
params: { user: { email: "tad@example.com" } },
|
|
215
|
-
company: company
|
|
217
|
+
def test_rejects_both_user_id_and_email
|
|
218
|
+
result = Search::LookupAction.lookup(
|
|
219
|
+
params: { user_id: 1, email: "tad@example.com" }
|
|
216
220
|
)
|
|
217
221
|
|
|
218
222
|
assert_UnprocessableContent(result)
|
|
219
|
-
assert_includes result[:json][:data][:
|
|
223
|
+
assert_includes result[:json][:data][:user_id], "provide one, not both"
|
|
220
224
|
end
|
|
221
225
|
end
|
|
222
226
|
```
|
|
223
227
|
|
|
224
|
-
|
|
228
|
+
### Actions Without a Schema
|
|
225
229
|
|
|
226
|
-
|
|
230
|
+
Not every action needs parameter validation:
|
|
227
231
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 })
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
233
242
|
|
|
234
243
|
## Requirements
|
|
235
244
|
|
|
236
245
|
- Ruby >= 3.2
|
|
237
|
-
- [dry-validation](https://dry-rb.org/gems/dry-validation/) ~> 1.10 — ActionFigure uses dry-validation for schema validation
|
|
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.
|
|
238
247
|
- Rails is not required, but ActionFigure is designed for Rails controller patterns
|
|
239
248
|
|
|
240
249
|
## License
|
data/docs/actions.md
CHANGED
|
@@ -44,12 +44,28 @@ end
|
|
|
44
44
|
|
|
45
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
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
|
+
```
|
|
62
|
+
|
|
47
63
|
### Disambiguation with `entry_point`
|
|
48
64
|
|
|
49
|
-
If a class ends up with more than one public instance method, ActionFigure cannot determine which one to use and raises an `
|
|
65
|
+
If a class ends up with more than one public instance method, ActionFigure cannot determine which one to use and raises an `IndeterminateEntryPointError`:
|
|
50
66
|
|
|
51
67
|
```
|
|
52
|
-
ActionFigure::
|
|
68
|
+
ActionFigure::IndeterminateEntryPointError: Multiple public methods defined in Orders::SearchAction:
|
|
53
69
|
:search and :format_results. Either make one private or declare
|
|
54
70
|
`entry_point :search` to disambiguate.
|
|
55
71
|
```
|
|
@@ -444,7 +460,7 @@ ActionFigure.configure do |config|
|
|
|
444
460
|
end
|
|
445
461
|
```
|
|
446
462
|
|
|
447
|
-
The global
|
|
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).
|
|
448
464
|
|
|
449
465
|
---
|
|
450
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
|
|
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
|
|
34
|
-
|
|
35
|
-
| `action`
|
|
36
|
-
| `
|
|
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
|
|
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
|
)
|
data/docs/configuration.md
CHANGED
|
@@ -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` |
|
|
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
|
|
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`:
|
data/docs/custom-formatters.md
CHANGED
|
@@ -50,8 +50,10 @@ module WrappedFormatter
|
|
|
50
50
|
{ json: body, status: :created }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def Accepted(resource: nil)
|
|
54
|
-
|
|
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 }
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
def UnprocessableContent(errors:)
|
data/docs/testing.md
CHANGED
|
@@ -36,6 +36,8 @@ end
|
|
|
36
36
|
| `assert_Conflict(result)` | `:conflict` |
|
|
37
37
|
| `assert_PaymentRequired(result)` | `:payment_required` |
|
|
38
38
|
|
|
39
|
+
These helpers compare **only `result[:status]`** against the Rack-style symbol Rails uses in **`render`** — they **do not** assert on **`[:json]`** keys, payloads, or error message text. Combine them with assertions on **`result[:json]`** (or matchers on the body your formatter produces) whenever shape matters.
|
|
40
|
+
|
|
39
41
|
All assertions accept an optional second argument for a custom failure message:
|
|
40
42
|
|
|
41
43
|
```ruby
|
|
@@ -61,6 +63,8 @@ Require the helper in your spec support file. No `include` is needed -- the matc
|
|
|
61
63
|
require "action_figure/testing/rspec"
|
|
62
64
|
```
|
|
63
65
|
|
|
66
|
+
**Load order:** require this library **after** RSpec Core and expectations load (usual practice: append it toward the **bottom** of `spec/spec_helper.rb`, after any `require "rails_helper"` / `RSpec.configure` boilerplate from your app). ActionFigure pulls in **`rspec/matchers`**; minimalist scripts without the full **`rspec` CLI shim** must **`require "rspec/expectations"`** (and typically **`require "rspec/core"`**) *before* this file.
|
|
67
|
+
|
|
64
68
|
### Matchers
|
|
65
69
|
|
|
66
70
|
| Matcher | Expected status |
|
|
@@ -74,6 +78,18 @@ require "action_figure/testing/rspec"
|
|
|
74
78
|
| `be_Forbidden` | `:forbidden` |
|
|
75
79
|
| `be_Conflict` | `:conflict` |
|
|
76
80
|
| `be_PaymentRequired` | `:payment_required` |
|
|
81
|
+
| `have_action_json` | `result[:json]` matches `a_hash_including(fragment)` |
|
|
82
|
+
|
|
83
|
+
Like the Minitest helpers, each **`be_*`** matcher compares **only `result[:status]`** — **`[:json]`** is ignored unless you assert on it separately. Use **`have_action_json`** when you want a focused assertion against the **`json`** body (compose with **`a_hash_including`** for nested subsets):
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
expect(result).to be_Ok
|
|
87
|
+
expect(result).to have_action_json(status: "success")
|
|
88
|
+
expect(result).to have_action_json(
|
|
89
|
+
status: "success",
|
|
90
|
+
data: a_hash_including(name: "Jane")
|
|
91
|
+
)
|
|
92
|
+
```
|
|
77
93
|
|
|
78
94
|
Matchers support negation:
|
|
79
95
|
|
data/docs/validation.md
CHANGED
|
@@ -17,6 +17,8 @@ If no `params_schema` is defined, `params:` passes through to your `#call` metho
|
|
|
17
17
|
|
|
18
18
|
`params_schema` accepts a block written in the [dry-schema](https://dry-rb.org/gems/dry-schema/) DSL. It defines the shape of your input: which keys are allowed, which are required, and what types they must be.
|
|
19
19
|
|
|
20
|
+
Each action calls **`params_schema` at most once** — a second call raises **`ArgumentError`**.
|
|
21
|
+
|
|
20
22
|
```ruby
|
|
21
23
|
class Users::CreateAction
|
|
22
24
|
include ActionFigure[:jsend]
|
|
@@ -100,6 +102,8 @@ end
|
|
|
100
102
|
ArgumentError: rules requires params_schema to be defined
|
|
101
103
|
```
|
|
102
104
|
|
|
105
|
+
- `params_schema` **may only be called once per action class**. Calling it again raises **`ArgumentError`**, so your schema cannot be replaced in a way that could silently confuse or strand an existing **`rules`** block.
|
|
106
|
+
|
|
103
107
|
- Inside a rule block, access validated values with `values[:field]`.
|
|
104
108
|
- Add an error to a specific field with `key(:field).failure("message")`, or `key.failure("message")` when the rule is scoped to a single field via `rule(:field)`.
|
|
105
109
|
- Multiple rules can target the same field. All rules run even if earlier ones fail -- errors accumulate.
|
data/lib/action_figure/core.rb
CHANGED
|
@@ -43,10 +43,9 @@ module ActionFigure
|
|
|
43
43
|
# subclasses. Define each action class independently.
|
|
44
44
|
module ClassMethods
|
|
45
45
|
def params_schema(&block)
|
|
46
|
-
if @params_schema_block
|
|
46
|
+
if @params_schema_block
|
|
47
47
|
raise ArgumentError,
|
|
48
|
-
"params_schema already defined
|
|
49
|
-
"redefining it would silently drop the existing rules block"
|
|
48
|
+
"params_schema already defined — each action class may declare only one schema"
|
|
50
49
|
end
|
|
51
50
|
|
|
52
51
|
@params_schema_block = block
|
|
@@ -99,12 +98,13 @@ module ActionFigure
|
|
|
99
98
|
end
|
|
100
99
|
|
|
101
100
|
def method_added(name)
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
disallow_action_initialize(name)
|
|
102
|
+
|
|
103
|
+
return if @explicit_entry_point || !public_method_defined?(name)
|
|
104
104
|
return unless instance_method(name).owner == self
|
|
105
105
|
|
|
106
106
|
if @entry_point_name
|
|
107
|
-
raise
|
|
107
|
+
raise IndeterminateEntryPointError,
|
|
108
108
|
"Multiple public methods defined in #{self}: " \
|
|
109
109
|
":#{@entry_point_name} and :#{name}. " \
|
|
110
110
|
"Either make one private or declare " \
|
|
@@ -119,6 +119,16 @@ module ActionFigure
|
|
|
119
119
|
super
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
+
def disallow_action_initialize(method_name)
|
|
123
|
+
return unless method_name == :initialize
|
|
124
|
+
return unless instance_method(:initialize).owner == self
|
|
125
|
+
|
|
126
|
+
raise InitializationNotSupportedError,
|
|
127
|
+
"#{self} must not define initialize — ActionFigure invokes new with no " \
|
|
128
|
+
"arguments. Pass dependencies via the entry method's keyword arguments " \
|
|
129
|
+
"or use class-level collaborators."
|
|
130
|
+
end
|
|
131
|
+
|
|
122
132
|
def build_contract
|
|
123
133
|
schema_block = @params_schema_block
|
|
124
134
|
rules_block = @rules_block
|
|
@@ -158,7 +168,10 @@ module ActionFigure
|
|
|
158
168
|
private
|
|
159
169
|
|
|
160
170
|
def notify
|
|
161
|
-
payload = {
|
|
171
|
+
payload = {
|
|
172
|
+
action: name,
|
|
173
|
+
entry_point: entry_point_name
|
|
174
|
+
}
|
|
162
175
|
ActiveSupport::Notifications.instrument("process.action_figure", payload) do
|
|
163
176
|
result = yield
|
|
164
177
|
payload[:status] = result[:status]
|
|
@@ -170,6 +183,8 @@ module ActionFigure
|
|
|
170
183
|
private
|
|
171
184
|
|
|
172
185
|
def normalize_params(kwargs)
|
|
186
|
+
return kwargs unless contract
|
|
187
|
+
|
|
173
188
|
raw = kwargs[:params]
|
|
174
189
|
return kwargs unless raw.respond_to?(:to_unsafe_h)
|
|
175
190
|
|
|
@@ -190,11 +205,31 @@ module ActionFigure
|
|
|
190
205
|
def check_extra_params(raw_params, result)
|
|
191
206
|
return unless ActionFigure.configuration.whiny_extra_params
|
|
192
207
|
|
|
193
|
-
|
|
194
|
-
return if
|
|
208
|
+
errors = find_extra_keys(raw_params, result.to_h)
|
|
209
|
+
return if errors.empty?
|
|
195
210
|
|
|
196
|
-
errors = extra_keys.to_h { |k| [k, ["is not allowed"]] }
|
|
197
211
|
UnprocessableContent(errors: errors)
|
|
198
212
|
end
|
|
213
|
+
|
|
214
|
+
def find_extra_keys(raw, validated, prefix = nil)
|
|
215
|
+
top_level = extra_keys_at_level(raw, validated, prefix)
|
|
216
|
+
nested = nested_extra_keys(raw, validated, prefix)
|
|
217
|
+
top_level.merge(nested)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def extra_keys_at_level(raw, validated, prefix)
|
|
221
|
+
(raw.keys.map(&:to_sym) - validated.keys).to_h do |k|
|
|
222
|
+
[prefix ? :"#{prefix}.#{k}" : k, ["is not allowed"]]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def nested_extra_keys(raw, validated, prefix)
|
|
227
|
+
validated.each_with_object({}) do |(key, value), errors|
|
|
228
|
+
next unless value.is_a?(Hash) && raw[key].is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
nested_prefix = [prefix, key].compact.join(".")
|
|
231
|
+
errors.merge!(find_extra_keys(raw[key], value, nested_prefix))
|
|
232
|
+
end
|
|
233
|
+
end
|
|
199
234
|
end
|
|
200
235
|
end
|
|
@@ -5,6 +5,8 @@ module ActionFigure
|
|
|
5
5
|
# Include this in your formatter module to get a NoContent default
|
|
6
6
|
# and to signal that your module implements the formatter interface.
|
|
7
7
|
module Formatter
|
|
8
|
+
# Response helper names every formatter must define (+NoContent+ lives on +Formatter+, not required here).
|
|
9
|
+
# Update every built-in formatter when you extend this list; +register_formatter+ validates against it at load time.
|
|
8
10
|
REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired].freeze
|
|
9
11
|
|
|
10
12
|
def NoContent
|
|
@@ -48,15 +48,20 @@ module ActionFigure
|
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
-
def convert_errors(errors, status)
|
|
51
|
+
def convert_errors(errors, status, prefix = "/data/attributes")
|
|
52
52
|
errors.flat_map do |field, messages|
|
|
53
|
-
pointer = field.to_sym == :base ? "/data" : "
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
pointer = field.to_sym == :base ? "/data" : "#{prefix}/#{field}"
|
|
54
|
+
|
|
55
|
+
if messages.is_a?(Hash)
|
|
56
|
+
convert_errors(messages, status, pointer)
|
|
57
|
+
else
|
|
58
|
+
messages.map do |message|
|
|
59
|
+
{
|
|
60
|
+
status: status,
|
|
61
|
+
detail: message,
|
|
62
|
+
source: { pointer: pointer }
|
|
63
|
+
}
|
|
64
|
+
end
|
|
60
65
|
end
|
|
61
66
|
end
|
|
62
67
|
end
|
|
@@ -13,6 +13,7 @@ module ActionFigure
|
|
|
13
13
|
# RSpec.describe Users::Create do
|
|
14
14
|
# it "returns ok" do
|
|
15
15
|
# expect(Users::Create.call(params: ...)).to be_Ok
|
|
16
|
+
# expect(Users::Create.call(params: ...)).to have_action_json(status: "success")
|
|
16
17
|
# end
|
|
17
18
|
# end
|
|
18
19
|
module RSpec
|
|
@@ -39,6 +40,33 @@ module ActionFigure
|
|
|
39
40
|
end
|
|
40
41
|
end
|
|
41
42
|
end
|
|
43
|
+
|
|
44
|
+
# Asserts against +result[:json]+ using +a_hash_including+ (nested matchers allowed).
|
|
45
|
+
::RSpec::Matchers.define :have_action_json do |expected_fragment|
|
|
46
|
+
include ::RSpec::Matchers
|
|
47
|
+
|
|
48
|
+
match do |result|
|
|
49
|
+
@inner_matcher ||= a_hash_including(expected_fragment)
|
|
50
|
+
next false unless result.is_a?(Hash) && result.key?(:json)
|
|
51
|
+
|
|
52
|
+
@inner_matcher.matches?(result[:json])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
failure_message do |result|
|
|
56
|
+
if !result.is_a?(Hash)
|
|
57
|
+
"expected an ActionFigure result hash, got #{result.inspect}"
|
|
58
|
+
elsif !result.key?(:json)
|
|
59
|
+
"expected #{result.inspect} to include key :json (ActionFigure render hash)"
|
|
60
|
+
else
|
|
61
|
+
"expected result[:json] to #{@inner_matcher.description}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
failure_message_when_negated do |result|
|
|
66
|
+
@inner_matcher ||= a_hash_including(expected_fragment)
|
|
67
|
+
"#{result.inspect} was expected not to match #{@inner_matcher.description}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
42
70
|
end
|
|
43
71
|
end
|
|
44
72
|
end
|
data/lib/action_figure.rb
CHANGED
|
@@ -15,7 +15,17 @@ module ActionFigure
|
|
|
15
15
|
extend Configuration
|
|
16
16
|
extend FormatRegistry
|
|
17
17
|
|
|
18
|
-
class
|
|
18
|
+
class IndeterminateEntryPointError < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Backwards-compatible alias for the misspelled constant shipped through 0.6.0.
|
|
21
|
+
# Remove in the next minor release after Unreleased.
|
|
22
|
+
IndeterminantEntryPointError = IndeterminateEntryPointError
|
|
23
|
+
deprecate_constant :IndeterminantEntryPointError
|
|
24
|
+
|
|
25
|
+
# Raised when an action class defines +initialize+. ActionFigure builds instances with
|
|
26
|
+
# +new+ and passes no constructor arguments; use keyword arguments on the entry method
|
|
27
|
+
# or class-level state instead of custom initializers.
|
|
28
|
+
class InitializationNotSupportedError < StandardError; end
|
|
19
29
|
|
|
20
30
|
register_formatter(jsend: Formatters::Jsend)
|
|
21
31
|
register_formatter(jsonapi: Formatters::JsonApi)
|
data/sig/action_figure.rbs
CHANGED
|
@@ -9,7 +9,13 @@ type ActionFigure::error_hash = Hash[Symbol, Array[String]]
|
|
|
9
9
|
module ActionFigure
|
|
10
10
|
VERSION: String
|
|
11
11
|
|
|
12
|
-
class
|
|
12
|
+
class IndeterminateEntryPointError < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Deprecated alias for IndeterminateEntryPointError (misspelled constant from <= 0.6.0).
|
|
16
|
+
IndeterminantEntryPointError: Class
|
|
17
|
+
|
|
18
|
+
class InitializationNotSupportedError < StandardError
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
extend Configuration
|
|
@@ -76,12 +82,15 @@ module ActionFigure
|
|
|
76
82
|
def contract: () -> untyped
|
|
77
83
|
end
|
|
78
84
|
|
|
79
|
-
def entry_point_name: () -> Symbol
|
|
85
|
+
def entry_point_name: () -> Symbol?
|
|
80
86
|
def contract: () -> untyped
|
|
81
87
|
def validated_call: (**untyped kwargs) -> ActionFigure::response
|
|
82
88
|
|
|
83
89
|
# ActiveSupport::Notifications instrumentation
|
|
84
90
|
module Notifications
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def notify: () { () -> ActionFigure::response } -> ActionFigure::response
|
|
85
94
|
end
|
|
86
95
|
end
|
|
87
96
|
|
|
@@ -96,6 +105,8 @@ module ActionFigure
|
|
|
96
105
|
def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
97
106
|
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
98
107
|
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
108
|
+
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
109
|
+
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
99
110
|
end
|
|
100
111
|
|
|
101
112
|
# JSend-formatted responses
|
|
@@ -108,6 +119,8 @@ module ActionFigure
|
|
|
108
119
|
def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
109
120
|
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
110
121
|
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
122
|
+
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
123
|
+
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
111
124
|
end
|
|
112
125
|
|
|
113
126
|
# JSON:API-formatted responses
|
|
@@ -120,6 +133,8 @@ module ActionFigure
|
|
|
120
133
|
def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
121
134
|
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
122
135
|
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
136
|
+
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
137
|
+
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
123
138
|
|
|
124
139
|
# Simple resource serialization for JSON:API
|
|
125
140
|
class Resource
|
|
@@ -137,6 +152,8 @@ module ActionFigure
|
|
|
137
152
|
def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
138
153
|
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
139
154
|
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
155
|
+
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
156
|
+
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
140
157
|
end
|
|
141
158
|
end
|
|
142
159
|
|
|
@@ -150,6 +167,8 @@ module ActionFigure
|
|
|
150
167
|
def assert_UnprocessableContent: (ActionFigure::response result, ?String? msg) -> void
|
|
151
168
|
def assert_NotFound: (ActionFigure::response result, ?String? msg) -> void
|
|
152
169
|
def assert_Forbidden: (ActionFigure::response result, ?String? msg) -> void
|
|
170
|
+
def assert_Conflict: (ActionFigure::response result, ?String? msg) -> void
|
|
171
|
+
def assert_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void
|
|
153
172
|
end
|
|
154
173
|
|
|
155
174
|
# RSpec custom matchers (be_Ok, be_Created, etc.)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: action_figure
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tad Thorley
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: concurrent-ruby
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: dry-validation
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -29,6 +43,7 @@ executables: []
|
|
|
29
43
|
extensions: []
|
|
30
44
|
extra_rdoc_files: []
|
|
31
45
|
files:
|
|
46
|
+
- CHANGELOG.md
|
|
32
47
|
- LICENSE.txt
|
|
33
48
|
- README.md
|
|
34
49
|
- Rakefile
|
|
@@ -61,6 +76,7 @@ licenses:
|
|
|
61
76
|
metadata:
|
|
62
77
|
homepage_uri: https://github.com/phaedryx/action_figure
|
|
63
78
|
source_code_uri: https://github.com/phaedryx/action_figure
|
|
79
|
+
changelog_uri: https://github.com/phaedryx/action_figure/blob/main/CHANGELOG.md
|
|
64
80
|
rubygems_mfa_required: 'true'
|
|
65
81
|
rdoc_options: []
|
|
66
82
|
require_paths:
|
|
@@ -76,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
76
92
|
- !ruby/object:Gem::Version
|
|
77
93
|
version: '0'
|
|
78
94
|
requirements: []
|
|
79
|
-
rubygems_version: 4.0.
|
|
95
|
+
rubygems_version: 4.0.3
|
|
80
96
|
specification_version: 4
|
|
81
97
|
summary: Fully-articulated controller actions
|
|
82
98
|
test_files: []
|