action_figure 0.1.0 → 0.5.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: 2afc60d26a2b7f19aa7a5e8e1e438dba5f0400688c9dd0b5ec184f0307484366
4
+ data.tar.gz: 346713da33afd14d6ea9d7862fbd7e71030d116e467664d6c73f19397e9a54c0
5
5
  SHA512:
6
- metadata.gz: d79e820da891b146d1a0de45d1a5460862b39e0013fc44f3393ce40910a7e80187e1a05e60c5e2e24199e9bd52bf940af63fa12beac6830ed0a7d04e5658d8e0
7
- data.tar.gz: c4afb1571646c7ac0a7f2e5968604fdeba0809976f2e18dddbc8e102b60fd886e6f12b2a60e4c6af8bceaa5fd71d22dec10dc95f9ee044f67075a93797d23152
6
+ metadata.gz: 2dba3a5b85d8f8bd55ebcfca08fb9eeedb508e2ff2d5f16cd8c971c0e3eb5586ab614922b341c90aaed4ad4e3607eefd71c7bd76e881592b45f5a1ac4688e9f8
7
+ data.tar.gz: 9a796394dc06f986ec4cbeef0e7e9cf3e3a9a80aaa15b51edb16201e89db815e86f8ccc3b816366543c7a8878327fd8f2be9fee11a4fbfe4adc47fade36e3879
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,269 @@
1
1
  # ActionFigure
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ Fully-articulated controller actions.
4
4
 
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.
5
+ ---
6
+ > #### Table of Contents
7
+ > [Installation](#installation)<br>
8
+ > [Quick Start](#quick-start)<br>
9
+ > [How It Works](#how-it-works)<br>
10
+ > [Features](#features)<br>
11
+ > [Full Example](#full-example)<br>
12
+ > [Design Philosophy](#design-philosophy)<br>
13
+ > [Requirements](#requirements)<br>
14
+ > [License](#license)
15
+ ---
16
+
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.
6
18
 
7
19
  ## Installation
8
20
 
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.
21
+ Add to your Gemfile and `bundle install`:
22
+
23
+ ```ruby
24
+ gem "action_figure"
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ **1. Start with what the action should do.**
30
+
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
+ )
10
42
 
11
- Install the gem and add to the application's Gemfile by executing:
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
48
+ end
12
49
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
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
+ )
56
+
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
63
+ end
64
+ end
15
65
  ```
16
66
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
67
+ **2. Define the action class.**
68
+
69
+ ```ruby
70
+ # app/actions/users/create_action.rb
71
+ class Users::CreateAction
72
+ include ActionFigure[:jsend]
73
+
74
+ params_schema do
75
+ required(:user).hash do
76
+ required(:name).filled(:string)
77
+ required(:email).filled(:string)
78
+ end
79
+ end
18
80
 
19
- ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
81
+ def call(params:, company:)
82
+ user = company.users.create(params[:user])
83
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
84
+
85
+ Created(resource: user.as_json(only: %i[id name email]))
86
+ end
87
+ end
21
88
  ```
22
89
 
23
- ## Usage
90
+ **3. Call it from your controller.**
91
+
92
+ ```ruby
93
+ class UsersController < ApplicationController
94
+ def create
95
+ render Users::CreateAction.call(params:, company: current_company)
96
+ end
97
+ end
98
+ ```
99
+
100
+ ## How It Works
101
+
102
+ Every action class has three responsibilities:
103
+
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.
106
+ 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
+
108
+ ## Features
109
+
110
+ | Feature | Description |
111
+ |---------|-------------|
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`. |
113
+ | [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. |
114
+ | [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. |
116
+ | [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
117
+ | [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
+ | [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
119
+ | [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
120
+
121
+ ## Full Example
122
+
123
+ Here is a more complete action showing how validation, authorization, and response formatting work together.
124
+
125
+ **The action class:**
126
+
127
+ ```ruby
128
+ # app/actions/orders/create_action.rb
129
+ class Orders::CreateAction
130
+ include ActionFigure[:wrapped]
131
+
132
+ 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)
138
+ end
139
+
140
+ rules do
141
+ all_rule(:gift_message, :gift_recipient_email,
142
+ "gift fields must be provided together or not at all")
143
+ end
144
+
145
+ def call(params:, current_user:)
146
+ if current_user.unpaid_balance?
147
+ return Forbidden(errors: { base: ["unpaid balance on account"] })
148
+ end
24
149
 
25
- TODO: Write usage instructions here
150
+ item = Item.find_by(id: params[:item_id])
151
+ return NotFound(errors: { item_id: ["item not found"] }) unless item
26
152
 
27
- ## Development
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:)
162
+ end
163
+ end
164
+ ```
165
+
166
+ **The controller:**
167
+
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:**
177
+
178
+ ```ruby
179
+ # test/actions/orders/create_action_test.rb
180
+ require "action_figure/testing/minitest"
181
+
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
+ )
193
+
194
+ assert_Created(result)
195
+ assert_equal item.id, result[:json][:data]["item_id"]
196
+ assert_equal 2, result[:json][:data]["quantity"]
197
+ end
198
+
199
+ def test_forbidden_with_unpaid_balance
200
+ user = User.create!(name: "Tud", balance: -1)
201
+
202
+ result = Orders::CreateAction.call(
203
+ params: { item_id: 1, quantity: 1 },
204
+ current_user: user
205
+ )
206
+
207
+ assert_Forbidden(result)
208
+ assert_includes result[:json][:errors][:base], "unpaid balance on account"
209
+ end
210
+
211
+ def test_not_found_when_item_missing
212
+ user = User.create!(name: "Tad")
213
+
214
+ result = Orders::CreateAction.call(
215
+ params: { item_id: 999, quantity: 1 },
216
+ current_user: user
217
+ )
218
+
219
+ assert_NotFound(result)
220
+ assert_includes result[:json][:errors][:item_id], "item not found"
221
+ end
222
+
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
230
+ )
231
+
232
+ assert_UnprocessableContent(result)
233
+ assert_includes result[:json][:errors][:quantity], "exceeds available stock"
234
+ end
235
+
236
+ def test_rejects_partial_gift_fields
237
+ user = User.create!(name: "Tad")
238
+ item = Item.create!(name: "Widget", price: 29.00)
239
+
240
+ result = Orders::CreateAction.call(
241
+ params: { item_id: item.id, quantity: 1, gift_message: "Enjoy!" },
242
+ current_user: user
243
+ )
244
+
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"
250
+ end
251
+ end
252
+ ```
28
253
 
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.
254
+ ## Design Philosophy
30
255
 
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).
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
32
261
 
33
- ## Contributing
262
+ ## Requirements
34
263
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/action_figure.
264
+ - Ruby >= 3.2
265
+ - [dry-validation](https://dry-rb.org/gems/dry-validation/) ~> 1.10
266
+ - Rails is not required, but ActionFigure is designed for Rails controller patterns
36
267
 
37
268
  ## License
38
269