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.
@@ -0,0 +1,175 @@
1
+ # Custom Formatters
2
+
3
+ ## Overview
4
+
5
+ Beyond the built-in formats, ActionFigure lets you define your own response format. A formatter is a Ruby module that translates action outcomes (success, creation, validation failure, etc.) into the response shape your API expects. Once registered, a custom formatter works exactly like the built-in ones.
6
+
7
+ ## The Formatter Interface
8
+
9
+ A formatter is a module that includes `ActionFigure::Formatter` and defines methods for each outcome type.
10
+
11
+ Including `ActionFigure::Formatter` gives you:
12
+
13
+ - A default `NoContent` implementation that returns `{ status: :no_content }`.
14
+ - A contract enforced at registration time: your module **must** define all 6 required methods.
15
+
16
+ The required methods are:
17
+
18
+ | Method | Purpose |
19
+ |------------------------|----------------------------------------------|
20
+ | `Ok` | Successful retrieval or generic success |
21
+ | `Created` | Resource was created |
22
+ | `Accepted` | Request accepted for background processing |
23
+ | `UnprocessableContent` | Validation or schema rule failure |
24
+ | `NotFound` | Resource not found |
25
+ | `Forbidden` | Authorization failure |
26
+
27
+ `NoContent` is provided by the base module and does not need to be defined, but you can override it if your format requires a different shape.
28
+
29
+ Each method receives keyword arguments and must return a hash. The exact keywords depend on the outcome -- success methods receive `resource:` (and optionally `meta:`), while failure methods receive `errors:`.
30
+
31
+ ## Building a Custom Formatter
32
+
33
+ Here is the built-in `Wrapped` formatter as a reference. It wraps every response in a uniform `{ data:, errors:, status: }` envelope:
34
+
35
+ ```ruby
36
+ module WrappedFormatter
37
+ include ActionFigure::Formatter
38
+
39
+ def Ok(resource:, meta: nil)
40
+ body = { data: resource, errors: nil, status: "success" }
41
+ body[:meta] = meta if meta
42
+ { json: body, status: :ok }
43
+ end
44
+
45
+ def Created(resource:, meta: nil)
46
+ body = { data: resource, errors: nil, status: "success" }
47
+ body[:meta] = meta if meta
48
+ { json: body, status: :created }
49
+ end
50
+
51
+ def Accepted(resource: nil)
52
+ { json: { data: resource, errors: nil, status: "success" }, status: :accepted }
53
+ end
54
+
55
+ def UnprocessableContent(errors:)
56
+ { json: { data: nil, errors: errors, status: "error" }, status: :unprocessable_content }
57
+ end
58
+
59
+ def NotFound(errors:)
60
+ { json: { data: nil, errors: errors, status: "error" }, status: :not_found }
61
+ end
62
+
63
+ def Forbidden(errors:)
64
+ { json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
65
+ end
66
+ end
67
+ ```
68
+
69
+ All 6 required methods are defined. Success methods accept `resource:` and optionally `meta:`, while failure methods accept `errors:`. The `NoContent` method is inherited from the base `ActionFigure::Formatter` module and returns `{ status: :no_content }` with no JSON body -- override it if your format requires a different shape.
70
+
71
+ ## Registering Your Formatter
72
+
73
+ There are two ways to register a custom formatter.
74
+
75
+ **Direct registration:**
76
+
77
+ ```ruby
78
+ ActionFigure.register_formatter(wrapped: WrappedFormatter)
79
+ ```
80
+
81
+ **Via the configuration block:**
82
+
83
+ ```ruby
84
+ ActionFigure.configure do |config|
85
+ config.register(wrapped: WrappedFormatter)
86
+ end
87
+ ```
88
+
89
+ Both approaches accept keyword arguments where the key is a symbol naming the format and the value is the formatter module. You can register multiple formatters in a single call:
90
+
91
+ ```ruby
92
+ ActionFigure.register_formatter(
93
+ wrapped: WrappedFormatter,
94
+ legacy_v1: LegacyV1Formatter
95
+ )
96
+ ```
97
+
98
+ The name you choose (`:wrapped` in the examples above) is the symbol you will use everywhere else to reference this format.
99
+
100
+ ## Interface Validation
101
+
102
+ Registration is not just bookkeeping -- ActionFigure validates every formatter module before accepting it. The validation checks that all methods listed in `ActionFigure::Formatter::REQUIRED_METHODS` are defined on the module:
103
+
104
+ ```ruby
105
+ ActionFigure::Formatter::REQUIRED_METHODS
106
+ # => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden]
107
+ ```
108
+
109
+ If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent:
110
+
111
+ ```ruby
112
+ module IncompleteFormatter
113
+ include ActionFigure::Formatter
114
+
115
+ def Ok(resource:, **)
116
+ { status: :ok, json: resource }
117
+ end
118
+ end
119
+
120
+ ActionFigure.register_formatter(incomplete: IncompleteFormatter)
121
+ # => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted,
122
+ # UnprocessableContent, NotFound, Forbidden
123
+ ```
124
+
125
+ Validation is **atomic** when registering multiple formatters at once. If any single module in the batch fails validation, none of them are registered -- this ensures your registry always remains in a consistent state.
126
+
127
+ ```ruby
128
+ # Neither formatter is registered because LegacyV1Formatter is invalid.
129
+ ActionFigure.register_formatter(
130
+ wrapped: WrappedFormatter,
131
+ legacy_v1: LegacyV1Formatter # missing methods
132
+ )
133
+ # => ArgumentError (nothing was registered)
134
+ ```
135
+
136
+ ## Using Your Formatter
137
+
138
+ Once registered, use a custom formatter exactly like a built-in one.
139
+
140
+ **Per-action inclusion:**
141
+
142
+ ```ruby
143
+ class Articles::PublishAction
144
+ include ActionFigure[:wrapped]
145
+
146
+ def call(id:)
147
+ article = Article.find(id)
148
+ article.publish!
149
+ Ok(resource: article)
150
+ end
151
+ end
152
+ ```
153
+
154
+ **As the global default:**
155
+
156
+ ```ruby
157
+ ActionFigure.configure do |config|
158
+ config.register(wrapped: WrappedFormatter)
159
+ config.format = :wrapped
160
+ end
161
+ ```
162
+
163
+ ```ruby
164
+ class Articles::PublishAction
165
+ include ActionFigure # no format specified, global setting is used
166
+
167
+ def call(id:)
168
+ article = Article.find(id)
169
+ article.publish!
170
+ Ok(resource: article)
171
+ end
172
+ end
173
+ ```
174
+
175
+ Setting `config.format` makes every action use your formatter unless an individual action explicitly includes a different one. Per-action includes always take precedence over the global default.
@@ -0,0 +1,331 @@
1
+ # Integration Patterns
2
+
3
+ ActionFigure doesn't prescribe a serializer, authorization library, or pagination strategy. Pass any hash to `resource:` and it goes straight into the response envelope. This guide shows how popular gems plug into that pattern.
4
+
5
+ ---
6
+
7
+ ## Serialization
8
+
9
+ Every example below uses the same action — creating a user — so you can compare the serialization step directly.
10
+
11
+ ### Plain Hashes
12
+
13
+ No gem required. Use `as_json`, `slice`, or build the hash by hand:
14
+
15
+ ```ruby
16
+ class Users::CreateAction
17
+ include ActionFigure[:jsend]
18
+
19
+ params_schema do
20
+ required(:user).hash do
21
+ required(:email).filled(:string)
22
+ required(:name).filled(:string)
23
+ end
24
+ end
25
+
26
+ def call(params:, company:)
27
+ user = company.users.create(params[:user])
28
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
29
+
30
+ Created(resource: user.as_json(only: %i[id name email]))
31
+ end
32
+ end
33
+ ```
34
+
35
+ For more control, build the hash yourself:
36
+
37
+ ```ruby
38
+ def call(params:, company:)
39
+ user = company.users.create(params[:user])
40
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
41
+
42
+ resource = { id: user.id, name: user.name, email: user.email, initials: user.name.split.map(&:first).join }
43
+ Created(resource:)
44
+ end
45
+ ```
46
+
47
+ `as_json` and hand-rolled hashes work well for simple cases. When serialization logic grows — conditional fields, nested associations, computed attributes — a dedicated serializer keeps it out of the action.
48
+
49
+ ### Blueprinter
50
+
51
+ [Blueprinter](https://github.com/procore-oss/blueprinter) defines serialization with a declarative DSL:
52
+
53
+ ```ruby
54
+ class UserBlueprint < Blueprinter::Base
55
+ identifier :id
56
+ fields :name, :email
57
+ end
58
+ ```
59
+
60
+ ```ruby
61
+ class Users::CreateAction
62
+ include ActionFigure[:jsend]
63
+
64
+ params_schema do
65
+ required(:user).hash do
66
+ required(:email).filled(:string)
67
+ required(:name).filled(:string)
68
+ end
69
+ end
70
+
71
+ def call(params:, company:)
72
+ user = company.users.create(params[:user])
73
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
74
+
75
+ Created(resource: UserBlueprint.render_as_hash(user))
76
+ end
77
+ end
78
+ ```
79
+
80
+ Blueprinter supports views for different contexts:
81
+
82
+ ```ruby
83
+ class UserBlueprint < Blueprinter::Base
84
+ identifier :id
85
+ fields :name, :email
86
+
87
+ view :detailed do
88
+ association :company, blueprint: CompanyBlueprint
89
+ field :created_at
90
+ end
91
+ end
92
+
93
+ # In the action:
94
+ resource = UserBlueprint.render_as_hash(user, view: :detailed)
95
+ ```
96
+
97
+ ### Alba
98
+
99
+ [Alba](https://github.com/okuramasafumi/alba) is a fast serializer with a flexible DSL:
100
+
101
+ ```ruby
102
+ class UserResource
103
+ include Alba::Resource
104
+
105
+ attributes :id, :name, :email
106
+ end
107
+ ```
108
+
109
+ ```ruby
110
+ class Users::CreateAction
111
+ include ActionFigure[:jsend]
112
+
113
+ params_schema do
114
+ required(:user).hash do
115
+ required(:email).filled(:string)
116
+ required(:name).filled(:string)
117
+ end
118
+ end
119
+
120
+ def call(params:, company:)
121
+ user = company.users.create(params[:user])
122
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
123
+
124
+ Created(resource: UserResource.new(user).to_h)
125
+ end
126
+ end
127
+ ```
128
+
129
+ Alba supports conditional attributes and nested resources:
130
+
131
+ ```ruby
132
+ class UserResource
133
+ include Alba::Resource
134
+
135
+ attributes :id, :name, :email
136
+
137
+ attribute :company do |user|
138
+ CompanyResource.new(user.company).to_h
139
+ end
140
+ end
141
+ ```
142
+
143
+ ### Oj Serializers
144
+
145
+ [Oj Serializers](https://github.com/ElMassimo/oj_serializers) is optimized for performance using Oj:
146
+
147
+ ```ruby
148
+ class UserSerializer < Oj::Serializer
149
+ attributes :id, :name, :email
150
+ end
151
+ ```
152
+
153
+ ```ruby
154
+ class Users::CreateAction
155
+ include ActionFigure[:jsend]
156
+
157
+ params_schema do
158
+ required(:user).hash do
159
+ required(:email).filled(:string)
160
+ required(:name).filled(:string)
161
+ end
162
+ end
163
+
164
+ def call(params:, company:)
165
+ user = company.users.create(params[:user])
166
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
167
+
168
+ Created(resource: UserSerializer.one(user))
169
+ end
170
+ end
171
+ ```
172
+
173
+ For collections, use `many`:
174
+
175
+ ```ruby
176
+ class Users::IndexAction
177
+ include ActionFigure[:jsend]
178
+
179
+ def call(company:, **)
180
+ users = company.users.order(:name)
181
+ resource = UserSerializer.many(users)
182
+ Ok(resource:)
183
+ end
184
+ end
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Authorization
190
+
191
+ Authorization gems work naturally with action classes. Inject the current user from the controller and call the authorization check during orchestration.
192
+
193
+ ### Pundit
194
+
195
+ Call the [Pundit](https://github.com/varvet/pundit) policy directly inside the action:
196
+
197
+ ```ruby
198
+ class Users::DestroyAction
199
+ include ActionFigure[:jsend]
200
+
201
+ params_schema do
202
+ required(:id).filled(:integer)
203
+ end
204
+
205
+ def call(params:, current_user:)
206
+ user = User.find(params[:id])
207
+ unless UserPolicy.new(current_user, user).destroy?
208
+ return Forbidden(errors: { base: ["not authorized to delete this user"] })
209
+ end
210
+ user.destroy!
211
+ NoContent()
212
+ end
213
+ end
214
+ ```
215
+
216
+ ```ruby
217
+ class UsersController < ApplicationController
218
+ def destroy
219
+ render Users::DestroyAction.call(params:, current_user: current_user)
220
+ end
221
+ end
222
+ ```
223
+
224
+ ### CanCanCan
225
+
226
+ With [CanCanCan](https://github.com/CanCanCommunity/cancancan), build the ability from the current user:
227
+
228
+ ```ruby
229
+ class Users::DestroyAction
230
+ include ActionFigure[:jsend]
231
+
232
+ params_schema do
233
+ required(:id).filled(:integer)
234
+ end
235
+
236
+ def call(params:, current_user:)
237
+ user = User.find(params[:id])
238
+ if Ability.new(current_user).can?(:destroy, user)
239
+ user.destroy!
240
+ NoContent()
241
+ else
242
+ Forbidden(errors: { base: ["not authorized to delete this user"] })
243
+ end
244
+ end
245
+ end
246
+ ```
247
+
248
+ ```ruby
249
+ class UsersController < ApplicationController
250
+ def destroy
251
+ render Users::DestroyAction.call(params:, current_user: current_user)
252
+ end
253
+ end
254
+ ```
255
+
256
+ In both cases, authorization failures return a `Forbidden` response through the same formatter pipeline as everything else -- no exceptions, no controller rescue needed.
257
+
258
+ ---
259
+
260
+ ## Pagination
261
+
262
+ ### Cursor Pagination
263
+
264
+ For paginated lists, accept cursor params and delegate the query logic to a service object. The action orchestrates -- the service does the heavy lifting:
265
+
266
+ ```ruby
267
+ class Users::IndexAction
268
+ include ActionFigure[:jsend]
269
+
270
+ params_schema do
271
+ optional(:cursor).filled(:integer)
272
+ optional(:limit).filled(:integer)
273
+ end
274
+
275
+ def call(params:, company:, **)
276
+ page = UserQuery.page(company.users, cursor: params[:cursor], limit: params[:limit] || 20)
277
+ resource = UserSerializer.many(page.records)
278
+ Ok(resource:, meta: { next_cursor: page.next_cursor })
279
+ end
280
+ end
281
+ ```
282
+
283
+ ```ruby
284
+ class UsersController < ApplicationController
285
+ def index
286
+ render Users::IndexAction.call(params:, company: current_company)
287
+ end
288
+ end
289
+ ```
290
+
291
+ The action checks params, delegates to `UserQuery` for the actual query, and formats the response. `UserQuery` is a plain Ruby class that knows how to paginate -- the action doesn't need to.
292
+
293
+ ### activerecord_cursor_paginate
294
+
295
+ The same pattern works with [activerecord_cursor_paginate](https://github.com/fatkodima/activerecord_cursor_paginate):
296
+
297
+ ```ruby
298
+ class Users::IndexAction
299
+ include ActionFigure[:jsend]
300
+
301
+ params_schema do
302
+ optional(:cursor).filled(:string)
303
+ optional(:limit).filled(:integer)
304
+ end
305
+
306
+ def call(params:, company:, **)
307
+ page = company.users
308
+ .cursor_paginate(after: params[:cursor], limit: params[:limit] || 20, order: :name)
309
+ .fetch
310
+ resource = UserSerializer.many(page.records)
311
+ Ok(resource:, meta: { next_cursor: page.next_cursor, has_next: page.has_next? })
312
+ end
313
+ end
314
+ ```
315
+
316
+ ### Pagy
317
+
318
+ Or with [pagy](https://github.com/ddnexus/pagy):
319
+
320
+ ```ruby
321
+ class Users::IndexAction
322
+ include ActionFigure[:jsend]
323
+ include Pagy::Backend
324
+
325
+ def call(request:, company:, **)
326
+ pagy, users = pagy(:keyset, company.users.order(:name), request:)
327
+ resource = UserSerializer.many(users)
328
+ Ok(resource:, meta: { next: pagy.next })
329
+ end
330
+ end
331
+ ```