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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c29461ca24fe48d0143c6c861a738e37fa04fa9954b0fcc3336f8154dc16a30
4
- data.tar.gz: 93e527838e129363aafbba74751660831a54084cb159571fa71f0b9ef11322fc
3
+ metadata.gz: f2f1def52c6601cb91ff3b2cd2fc366efeef932c1fba3072932f674cbf1748af
4
+ data.tar.gz: da5c9b0ab274f7e6f1512560af168d4d44c21dc3875ac85c0f79998902fa5df1
5
5
  SHA512:
6
- metadata.gz: 311b1a051ee7caec1aece62143607ed2484720ab6a04398b66f091087a0febccacc91e1478c438bbca98b93d0d13cf57da6f12d8ae3caf47dd8bbb0b642c7994
7
- data.tar.gz: 4bd0d145727af9f7373301ad26abc7b65a08041071c07317cf3fb88a818961be09e834de1b857a5256c6012b2965e0aa2bf1da24943cd2219689416d14be5a22
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** extracts controller actions into classes that validate params, orchestrate work, and return render-ready responses. Your controller becomes:
16
+ **ActionFigure** makes your controller actions more usable and understandable. It turns this:
17
17
 
18
18
  ```ruby
19
- class OrdersController < ApplicationController
19
+ class ProjectsController < ApplicationController
20
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:
21
+ permitted = params.require(:project).permit(
22
+ :name, :description, settings: [:visibility, :notify_on_mention]
23
+ )
27
24
 
28
- ```ruby
29
- class Orders::CreateAction
30
- include ActionFigure[:wrapped]
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
- 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
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
- rules do
41
- all_rule(:gift_message, :gift_recipient_email,
42
- "gift fields must be provided together or not at all")
43
- end
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
- def create(params:, current_user:)
46
- if current_user.unpaid_balance?
47
- return Forbidden(errors: { base: ["unpaid balance on account"] })
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
- item = Item.find_by(id: params[:item_id])
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
- 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?
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
- resource = OrderBlueprint.render_as_hash(order, view: :confirmation)
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
- Param validation, cross-field rules, authorization, error handling, and response formatting — all in one place, all testable without a request:
63
+ into this:
67
64
 
68
65
  ```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"]
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
- 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
- )
73
+ ```ruby
74
+ class Projects::CreateAction
75
+ include ActionFigure[:jsend]
92
76
 
93
- assert_Forbidden(result)
94
- assert_includes result[:json][:errors][:base], "unpaid balance on account"
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 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
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
- def test_rejects_partial_gift_fields
110
- user = User.create!(name: "Tad")
111
- item = Item.create!(name: "Widget", price: 29.00)
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
- result = Orders::CreateAction.create(
114
- params: { item_id: item.id, quantity: 1, gift_message: "Enjoy!" },
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
- assert_UnprocessableContent(result)
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
- 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.
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, calling service objects, enqueuing jobs, or anything else the action requires. The action is the entry point, not necessarily where all the logic lives.
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
- ## Quick Start
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
- **1. Define the action class.**
156
+ Cross-parameter helpers make multi-field constraints declarative:
160
157
 
161
158
  ```ruby
162
- # app/actions/users/create_action.rb
163
- class Users::CreateAction
159
+ class Search::LookupAction
164
160
  include ActionFigure[:jsend]
165
161
 
166
162
  params_schema do
167
- required(:user).hash do
168
- required(:name).filled(:string)
169
- required(:email).filled(:string)
170
- end
163
+ optional(:user_id).filled(:integer)
164
+ optional(:email).filled(:string)
171
165
  end
172
166
 
173
- def create(params:, company:)
174
- user = company.users.create(params[:user])
175
- return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
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
- Created(resource: user.as_json(only: %i[id name email]))
174
+ Ok(resource: user.as_json)
178
175
  end
179
176
  end
180
177
  ```
181
178
 
182
- **2. Call it from your controller.**
179
+ ### Response Formatters
180
+
181
+ Choose a response envelope by name. The same helpers return different shapes:
183
182
 
184
183
  ```ruby
185
- class UsersController < ApplicationController
186
- def create
187
- render Users::CreateAction.create(params:, company: current_company)
188
- end
189
- end
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
- **3. Test it directly.**
201
+ ### Testing
202
+
203
+ Action classes are plain method calls — no request setup needed:
193
204
 
194
205
  ```ruby
195
- class Users::CreateActionTest < Minitest::Test
206
+ class Search::LookupActionTest < Minitest::Test
196
207
  include ActionFigure::Testing::Minitest
197
208
 
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
209
+ def test_finds_by_email
210
+ result = Search::LookupAction.lookup(
211
+ params: { email: "tad@example.com" }
204
212
  )
205
213
 
206
- assert_Created(result)
207
- assert_equal "Tad", result[:json][:data]["name"]
214
+ assert_Ok(result)
208
215
  end
209
216
 
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
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][:user][:name], "is missing"
223
+ assert_includes result[:json][:data][:user_id], "provide one, not both"
220
224
  end
221
225
  end
222
226
  ```
223
227
 
224
- ## Design Philosophy
228
+ ### Actions Without a Schema
225
229
 
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`.
230
+ Not every action needs parameter validation:
227
231
 
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
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 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.
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 `IndeterminantEntryPointError`:
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::IndeterminantEntryPointError: Multiple public methods defined in Orders::SearchAction:
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 version is accessible via `ActionFigure.configuration.api_version` but is not used as an automatic fallback for class-level `api_version`. Version values are independent per class -- they are not inherited by subclasses because version state is stored in class-level instance variables.
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 `.call` (or custom entry point) emits a `process.action_figure` event with the action class name, outcome status, and timing.
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 | Type | Description |
34
- |----------|--------|-------------|
35
- | `action` | String | The action class name, e.g. `"Users::CreateAction"` |
36
- | `status` | Symbol | The outcome status, e.g. `:ok`, `:created` |
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 -- validation, the `#call` method, and the formatted response. Both successful and failed outcomes are captured:
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
  )
@@ -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` | Default formatter name. Applies to any class that uses bare `include ActionFigure`. |
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`, enables `ActiveSupport::Notifications` events for action classes defined after the change. Requires ActiveSupport. |
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`:
@@ -50,8 +50,10 @@ module WrappedFormatter
50
50
  { json: body, status: :created }
51
51
  end
52
52
 
53
- def Accepted(resource: nil)
54
- { json: { data: resource, errors: nil, status: "success" }, status: :accepted }
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.
@@ -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 && @rules_block
46
+ if @params_schema_block
47
47
  raise ArgumentError,
48
- "params_schema already defined with rules " \
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
- return if @explicit_entry_point
103
- return unless public_method_defined?(name)
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 IndeterminantEntryPointError,
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 = { action: name }
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
- extra_keys = raw_params.keys.map(&:to_sym) - result.to_h.keys
194
- return if extra_keys.empty?
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
@@ -18,7 +18,7 @@ module ActionFigure
18
18
  def self.serialize_one(resource)
19
19
  {
20
20
  type: resource.class.model_name.element,
21
- id: resource.id.to_s,
21
+ id: resource.id&.to_s,
22
22
  attributes: resource.attributes.except("id")
23
23
  }
24
24
  end
@@ -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" : "/data/attributes/#{field}"
54
- messages.map do |message|
55
- {
56
- status: status,
57
- detail: message,
58
- source: { pointer: pointer }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionFigure
4
- VERSION = "0.6.0"
4
+ VERSION = "0.6.2"
5
5
  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 IndeterminantEntryPointError < StandardError; end
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)
@@ -9,7 +9,13 @@ type ActionFigure::error_hash = Hash[Symbol, Array[String]]
9
9
  module ActionFigure
10
10
  VERSION: String
11
11
 
12
- class IndeterminantEntryPointError < StandardError
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.0
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.8
95
+ rubygems_version: 4.0.3
80
96
  specification_version: 4
81
97
  summary: Fully-articulated controller actions
82
98
  test_files: []