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 +4 -4
- data/LICENSE.txt +1 -1
- data/README.md +219 -16
- data/docs/actions.md +495 -0
- data/docs/activesupport-notifications.md +113 -0
- data/docs/configuration.md +88 -0
- data/docs/custom-formatters.md +185 -0
- data/docs/integration-patterns.md +331 -0
- data/docs/response-formatters.md +1088 -0
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +272 -0
- data/docs/validation.md +294 -0
- data/lib/action_figure/configuration.rb +33 -0
- data/lib/action_figure/core.rb +200 -0
- data/lib/action_figure/format_registry.rb +40 -0
- data/lib/action_figure/formatter.rb +14 -0
- data/lib/action_figure/formatters/default.rb +49 -0
- data/lib/action_figure/formatters/jsend.rb +49 -0
- data/lib/action_figure/formatters/json_api/resource.rb +30 -0
- data/lib/action_figure/formatters/json_api.rb +65 -0
- data/lib/action_figure/formatters/wrapped.rb +49 -0
- data/lib/action_figure/testing/minitest.rb +58 -0
- data/lib/action_figure/testing/rspec.rb +44 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +68 -0
- data/sig/action_figure.rbs +157 -1
- metadata +26 -5
|
@@ -0,0 +1,185 @@
|
|
|
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 8 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
|
+
| `Conflict` | Resource state conflict or duplicate |
|
|
27
|
+
| `PaymentRequired` | Business billing or quota constraint |
|
|
28
|
+
|
|
29
|
+
`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.
|
|
30
|
+
|
|
31
|
+
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:`.
|
|
32
|
+
|
|
33
|
+
## Building a Custom Formatter
|
|
34
|
+
|
|
35
|
+
Here is the built-in `Wrapped` formatter as a reference. It wraps every response in a uniform `{ data:, errors:, status: }` envelope:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
module WrappedFormatter
|
|
39
|
+
include ActionFigure::Formatter
|
|
40
|
+
|
|
41
|
+
def Ok(resource:, meta: nil)
|
|
42
|
+
body = { data: resource, errors: nil, status: "success" }
|
|
43
|
+
body[:meta] = meta if meta
|
|
44
|
+
{ json: body, status: :ok }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def Created(resource:, meta: nil)
|
|
48
|
+
body = { data: resource, errors: nil, status: "success" }
|
|
49
|
+
body[:meta] = meta if meta
|
|
50
|
+
{ json: body, status: :created }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def Accepted(resource: nil)
|
|
54
|
+
{ json: { data: resource, errors: nil, status: "success" }, status: :accepted }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def UnprocessableContent(errors:)
|
|
58
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :unprocessable_content }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def NotFound(errors:)
|
|
62
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :not_found }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def Forbidden(errors:)
|
|
66
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def Conflict(errors:)
|
|
70
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :conflict }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def PaymentRequired(errors:)
|
|
74
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: :payment_required }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
All 8 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.
|
|
80
|
+
|
|
81
|
+
## Registering Your Formatter
|
|
82
|
+
|
|
83
|
+
There are two ways to register a custom formatter.
|
|
84
|
+
|
|
85
|
+
**Direct registration:**
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
ActionFigure.register_formatter(wrapped: WrappedFormatter)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Via the configuration block:**
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
ActionFigure.configure do |config|
|
|
95
|
+
config.register(wrapped: WrappedFormatter)
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
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:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
ActionFigure.register_formatter(
|
|
103
|
+
wrapped: WrappedFormatter,
|
|
104
|
+
legacy_v1: LegacyV1Formatter
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The name you choose (`:wrapped` in the examples above) is the symbol you will use everywhere else to reference this format.
|
|
109
|
+
|
|
110
|
+
## Interface Validation
|
|
111
|
+
|
|
112
|
+
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:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
ActionFigure::Formatter::REQUIRED_METHODS
|
|
116
|
+
# => [:Ok, :Created, :Accepted, :UnprocessableContent, :NotFound, :Forbidden, :Conflict, :PaymentRequired]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
If any required method is missing, registration raises an `ArgumentError` that lists exactly which methods are absent:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
module IncompleteFormatter
|
|
123
|
+
include ActionFigure::Formatter
|
|
124
|
+
|
|
125
|
+
def Ok(resource:, **)
|
|
126
|
+
{ status: :ok, json: resource }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
ActionFigure.register_formatter(incomplete: IncompleteFormatter)
|
|
131
|
+
# => ArgumentError: IncompleteFormatter is missing formatter methods: Created, Accepted,
|
|
132
|
+
# UnprocessableContent, NotFound, Forbidden, Conflict, PaymentRequired
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# Neither formatter is registered because LegacyV1Formatter is invalid.
|
|
139
|
+
ActionFigure.register_formatter(
|
|
140
|
+
wrapped: WrappedFormatter,
|
|
141
|
+
legacy_v1: LegacyV1Formatter # missing methods
|
|
142
|
+
)
|
|
143
|
+
# => ArgumentError (nothing was registered)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Using Your Formatter
|
|
147
|
+
|
|
148
|
+
Once registered, use a custom formatter exactly like a built-in one.
|
|
149
|
+
|
|
150
|
+
**Per-action inclusion:**
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
class Articles::PublishAction
|
|
154
|
+
include ActionFigure[:wrapped]
|
|
155
|
+
|
|
156
|
+
def call(id:)
|
|
157
|
+
article = Article.find(id)
|
|
158
|
+
article.publish!
|
|
159
|
+
Ok(resource: article)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**As the global default:**
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
ActionFigure.configure do |config|
|
|
168
|
+
config.register(wrapped: WrappedFormatter)
|
|
169
|
+
config.format = :wrapped
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class Articles::PublishAction
|
|
175
|
+
include ActionFigure # no format specified, global setting is used
|
|
176
|
+
|
|
177
|
+
def call(id:)
|
|
178
|
+
article = Article.find(id)
|
|
179
|
+
article.publish!
|
|
180
|
+
Ok(resource: article)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
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
|
+
```
|