action_figure 0.1.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b25804b8a5e0781e689bfcf6b9cf93c15feb8a2616d33e2fcfe01f59c86a97f
4
- data.tar.gz: 0ce167d5c40eedda9d2cb7aaa98e0f7fb063aa56bac5e8da8cfe5d3038e56ff2
3
+ metadata.gz: 6c29461ca24fe48d0143c6c861a738e37fa04fa9954b0fcc3336f8154dc16a30
4
+ data.tar.gz: 93e527838e129363aafbba74751660831a54084cb159571fa71f0b9ef11322fc
5
5
  SHA512:
6
- metadata.gz: d79e820da891b146d1a0de45d1a5460862b39e0013fc44f3393ce40910a7e80187e1a05e60c5e2e24199e9bd52bf940af63fa12beac6830ed0a7d04e5658d8e0
7
- data.tar.gz: c4afb1571646c7ac0a7f2e5968604fdeba0809976f2e18dddbc8e102b60fd886e6f12b2a60e4c6af8bceaa5fd71d22dec10dc95f9ee044f67075a93797d23152
6
+ metadata.gz: 311b1a051ee7caec1aece62143607ed2484720ab6a04398b66f091087a0febccacc91e1478c438bbca98b93d0d13cf57da6f12d8ae3caf47dd8bbb0b642c7994
7
+ data.tar.gz: 4bd0d145727af9f7373301ad26abc7b65a08041071c07317cf3fb88a818961be09e834de1b857a5256c6012b2965e0aa2bf1da24943cd2219689416d14be5a22
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 Tad Thorley
3
+ Copyright (c) 2026 Thomas (Tad) Thorley
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,38 +1,241 @@
1
1
  # ActionFigure
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ Fully-articulated controller actions.
4
+
5
+ ---
6
+ > #### Table of Contents
7
+ > [Installation](#installation)<br>
8
+ > [How It Works](#how-it-works)<br>
9
+ > [Features](#features)<br>
10
+ > [Quick Start](#quick-start)<br>
11
+ > [Design Philosophy](#design-philosophy)<br>
12
+ > [Requirements](#requirements)<br>
13
+ > [License](#license)
14
+ ---
15
+
16
+ **ActionFigure** extracts controller actions into classes that validate params, orchestrate work, and return render-ready responses. Your controller becomes:
17
+
18
+ ```ruby
19
+ class OrdersController < ApplicationController
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:
27
+
28
+ ```ruby
29
+ class Orders::CreateAction
30
+ include ActionFigure[:wrapped]
31
+
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
39
+
40
+ rules do
41
+ all_rule(:gift_message, :gift_recipient_email,
42
+ "gift fields must be provided together or not at all")
43
+ end
44
+
45
+ def create(params:, current_user:)
46
+ if current_user.unpaid_balance?
47
+ return Forbidden(errors: { base: ["unpaid balance on account"] })
48
+ end
49
+
50
+ item = Item.find_by(id: params[:item_id])
51
+ return NotFound(errors: { item_id: ["item not found"] }) unless item
52
+
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?
59
+
60
+ resource = OrderBlueprint.render_as_hash(order, view: :confirmation)
61
+ Created(resource:)
62
+ end
63
+ end
64
+ ```
65
+
66
+ Param validation, cross-field rules, authorization, error handling, and response formatting — all in one place, all testable without a request:
67
+
68
+ ```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"]
83
+ end
84
+
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
+ )
92
+
93
+ assert_Forbidden(result)
94
+ assert_includes result[:json][:errors][:base], "unpaid balance on account"
95
+ end
96
+
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
108
+
109
+ def test_rejects_partial_gift_fields
110
+ user = User.create!(name: "Tad")
111
+ item = Item.create!(name: "Widget", price: 29.00)
112
+
113
+ result = Orders::CreateAction.create(
114
+ params: { item_id: item.id, quantity: 1, gift_message: "Enjoy!" },
115
+ current_user: user
116
+ )
117
+
118
+ assert_UnprocessableContent(result)
119
+ assert_includes result[:json][:errors][:gift_message],
120
+ "gift fields must be provided together or not at all"
121
+ end
122
+ end
123
+ ```
4
124
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/action_figure`. To experiment with that code, run `bin/console` for an interactive prompt.
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.
6
126
 
7
127
  ## Installation
8
128
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
129
+ Add to your Gemfile and `bundle install`:
10
130
 
11
- Install the gem and add to the application's Gemfile by executing:
131
+ ```ruby
132
+ gem "action_figure"
133
+ ```
134
+
135
+ ## How It Works
136
+
137
+ Every action class has three responsibilities:
138
+
139
+ 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.
141
+ 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
+
143
+ ## Features
144
+
145
+ | Feature | Description |
146
+ |---------|-------------|
147
+ | [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`. |
148
+ | [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. |
149
+ | [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). |
150
+ | [Custom Formatters](docs/custom-formatters.md) | Define your own response envelope by implementing the formatter interface. Registration validates your module at load time. |
151
+ | [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. |
152
+ | [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
153
+ | [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. |
154
+ | [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
155
+ | [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
156
+
157
+ ## Quick Start
158
+
159
+ **1. Define the action class.**
160
+
161
+ ```ruby
162
+ # app/actions/users/create_action.rb
163
+ class Users::CreateAction
164
+ include ActionFigure[:jsend]
12
165
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
166
+ params_schema do
167
+ required(:user).hash do
168
+ required(:name).filled(:string)
169
+ required(:email).filled(:string)
170
+ end
171
+ end
172
+
173
+ def create(params:, company:)
174
+ user = company.users.create(params[:user])
175
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
176
+
177
+ Created(resource: user.as_json(only: %i[id name email]))
178
+ end
179
+ end
15
180
  ```
16
181
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
182
+ **2. Call it from your controller.**
18
183
 
19
- ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
184
+ ```ruby
185
+ class UsersController < ApplicationController
186
+ def create
187
+ render Users::CreateAction.create(params:, company: current_company)
188
+ end
189
+ end
21
190
  ```
22
191
 
23
- ## Usage
192
+ **3. Test it directly.**
193
+
194
+ ```ruby
195
+ class Users::CreateActionTest < Minitest::Test
196
+ include ActionFigure::Testing::Minitest
197
+
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
204
+ )
24
205
 
25
- TODO: Write usage instructions here
206
+ assert_Created(result)
207
+ assert_equal "Tad", result[:json][:data]["name"]
208
+ end
209
+
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
216
+ )
217
+
218
+ assert_UnprocessableContent(result)
219
+ assert_includes result[:json][:data][:user][:name], "is missing"
220
+ end
221
+ end
222
+ ```
26
223
 
27
- ## Development
224
+ ## Design Philosophy
28
225
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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`.
30
227
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
32
233
 
33
- ## Contributing
234
+ ## Requirements
34
235
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/action_figure.
236
+ - 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.
238
+ - Rails is not required, but ActionFigure is designed for Rails controller patterns
36
239
 
37
240
  ## License
38
241