action_figure 0.5.0 → 0.6.2

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