rails_claude_skills 0.1.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 +7 -0
- data/.github/ISSUE_TEMPLATE/bug_report.yml +134 -0
- data/.github/ISSUE_TEMPLATE/config.yml +11 -0
- data/.github/ISSUE_TEMPLATE/feature_request.yml +129 -0
- data/.github/ISSUE_TEMPLATE/question.yml +90 -0
- data/.github/dependabot.yml +19 -0
- data/.github/workflows/ci.yml +77 -0
- data/.github/workflows/release.yml +66 -0
- data/.rubocop.yml +52 -0
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +332 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +580 -0
- data/LICENSE.txt +21 -0
- data/README.md +544 -0
- data/Rakefile +8 -0
- data/lib/generators/claude/agent/agent_generator.rb +71 -0
- data/lib/generators/claude/agent/templates/agent.md.tt +62 -0
- data/lib/generators/claude/command/command_generator.rb +50 -0
- data/lib/generators/claude/command/templates/command.md.tt +28 -0
- data/lib/generators/claude/commands_library/create-pr.md +27 -0
- data/lib/generators/claude/commands_library/dbchange.md +19 -0
- data/lib/generators/claude/commands_library/quality.md +20 -0
- data/lib/generators/claude/commands_library/stimulus.md +19 -0
- data/lib/generators/claude/commands_library/turbo-feature.md +17 -0
- data/lib/generators/claude/install/install_generator.rb +211 -0
- data/lib/generators/claude/install/templates/README.md.tt +59 -0
- data/lib/generators/claude/install/templates/USAGE +28 -0
- data/lib/generators/claude/install/templates/agents/api-dev.md.tt +46 -0
- data/lib/generators/claude/install/templates/agents/fullstack-dev.md.tt +48 -0
- data/lib/generators/claude/install/templates/agents/rails-developer.md.tt +40 -0
- data/lib/generators/claude/install/templates/settings.local.json.tt +13 -0
- data/lib/generators/claude/rule/rule_generator.rb +175 -0
- data/lib/generators/claude/rule/templates/rule.md.tt +7 -0
- data/lib/generators/claude/rules_library/code-style.md +37 -0
- data/lib/generators/claude/rules_library/database.md +47 -0
- data/lib/generators/claude/rules_library/hotwire.md +56 -0
- data/lib/generators/claude/rules_library/security.md +54 -0
- data/lib/generators/claude/rules_library/testing.md +47 -0
- data/lib/generators/claude/skill/skill_generator.rb +196 -0
- data/lib/generators/claude/skill/templates/SKILL.md.tt +27 -0
- data/lib/generators/claude/skills_library/create-task-files/SKILL.md +311 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/bug.md +60 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/epic.md +47 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/issue.md +45 -0
- data/lib/generators/claude/skills_library/create-task-files/templates/user-story.md +57 -0
- data/lib/generators/claude/skills_library/minitest-testing/SKILL.md +398 -0
- data/lib/generators/claude/skills_library/minitest-testing/references/examples.md +889 -0
- data/lib/generators/claude/skills_library/plan-feature/SKILL.md +253 -0
- data/lib/generators/claude/skills_library/rails-api-controllers/SKILL.md +1041 -0
- data/lib/generators/claude/skills_library/rails-api-controllers/references/api-documentation.md +422 -0
- data/lib/generators/claude/skills_library/rails-api-controllers/references/serialization.md +456 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/SKILL.md +191 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/references/advanced.md +331 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/references/api-auth.md +266 -0
- data/lib/generators/claude/skills_library/rails-auth-with-devise/references/omniauth.md +194 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/SKILL.md +603 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/references/api-authorization.md +543 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/references/complex-permissions.md +572 -0
- data/lib/generators/claude/skills_library/rails-authorization-cancancan/references/multi-tenancy.md +373 -0
- data/lib/generators/claude/skills_library/rails-controllers/SKILL.md +514 -0
- data/lib/generators/claude/skills_library/rails-debugging/SKILL.md +260 -0
- data/lib/generators/claude/skills_library/rails-deployment/SKILL.md +437 -0
- data/lib/generators/claude/skills_library/rails-deployment/references/examples.md +901 -0
- data/lib/generators/claude/skills_library/rails-hotwire/SKILL.md +367 -0
- data/lib/generators/claude/skills_library/rails-jobs/MISSION_CONTROL_SETUP.md +639 -0
- data/lib/generators/claude/skills_library/rails-jobs/SKILL.md +704 -0
- data/lib/generators/claude/skills_library/rails-mailers/SKILL.md +549 -0
- data/lib/generators/claude/skills_library/rails-models/SKILL.md +379 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/SKILL.md +622 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/references/api-pagination.md +523 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/references/custom-themes.md +498 -0
- data/lib/generators/claude/skills_library/rails-pagination-kaminari/references/performance.md +478 -0
- data/lib/generators/claude/skills_library/rails-views/SKILL.md +508 -0
- data/lib/generators/claude/skills_library/refine-requirements/SKILL.md +226 -0
- data/lib/generators/claude/skills_library/refine-requirements/references/examples.md +344 -0
- data/lib/generators/claude/skills_library/refine-requirements/references/reference.md +298 -0
- data/lib/generators/claude/skills_library/rspec-testing/SKILL.md +572 -0
- data/lib/generators/claude/skills_library/rspec-testing/references/better_specs_guide.md +273 -0
- data/lib/generators/claude/skills_library/rspec-testing/references/thoughtbot_patterns.md +407 -0
- data/lib/generators/claude/skills_library/tailwindcss/SKILL.md +371 -0
- data/lib/generators/claude/views/views_generator.rb +113 -0
- data/lib/rails_claude_skills/railtie.rb +16 -0
- data/lib/rails_claude_skills/version.rb +5 -0
- data/lib/rails_claude_skills.rb +27 -0
- data/sig/rails_claude_skills.rbs +4 -0
- metadata +199 -0
data/lib/generators/claude/skills_library/rails-pagination-kaminari/references/api-pagination.md
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# API Pagination with Kaminari
|
|
2
|
+
|
|
3
|
+
Best practices and patterns for implementing pagination in JSON APIs using Kaminari.
|
|
4
|
+
|
|
5
|
+
## REST API Pagination
|
|
6
|
+
|
|
7
|
+
### Basic JSON Response
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
11
|
+
module Api
|
|
12
|
+
module V1
|
|
13
|
+
class PostsController < ApplicationController
|
|
14
|
+
def index
|
|
15
|
+
@posts = Post.page(params[:page]).per(per_page)
|
|
16
|
+
|
|
17
|
+
render json: {
|
|
18
|
+
data: @posts.map { |post| PostSerializer.new(post).as_json },
|
|
19
|
+
pagination: pagination_metadata(@posts)
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def per_page
|
|
26
|
+
[(params[:per_page] || 20).to_i, 100].min # Max 100 per page
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pagination_metadata(collection)
|
|
30
|
+
{
|
|
31
|
+
current_page: collection.current_page,
|
|
32
|
+
next_page: collection.next_page,
|
|
33
|
+
prev_page: collection.prev_page,
|
|
34
|
+
total_pages: collection.total_pages,
|
|
35
|
+
total_count: collection.total_count,
|
|
36
|
+
per_page: collection.limit_value
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Response Example
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"data": [
|
|
49
|
+
{
|
|
50
|
+
"id": 1,
|
|
51
|
+
"title": "First Post",
|
|
52
|
+
"body": "Content here..."
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"pagination": {
|
|
56
|
+
"current_page": 1,
|
|
57
|
+
"next_page": 2,
|
|
58
|
+
"prev_page": null,
|
|
59
|
+
"total_pages": 10,
|
|
60
|
+
"total_count": 245,
|
|
61
|
+
"per_page": 25
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Pagination Concern
|
|
67
|
+
|
|
68
|
+
Create a reusable concern for API pagination:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# app/controllers/concerns/api_paginatable.rb
|
|
72
|
+
module ApiPaginatable
|
|
73
|
+
extend ActiveSupport::Concern
|
|
74
|
+
|
|
75
|
+
included do
|
|
76
|
+
helper_method :pagination_meta, :pagination_links
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def paginate(collection)
|
|
80
|
+
collection
|
|
81
|
+
.page(params[:page] || 1)
|
|
82
|
+
.per(per_page_param)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def pagination_meta(collection)
|
|
86
|
+
{
|
|
87
|
+
current_page: collection.current_page,
|
|
88
|
+
next_page: collection.next_page,
|
|
89
|
+
prev_page: collection.prev_page,
|
|
90
|
+
total_pages: collection.total_pages,
|
|
91
|
+
total_count: collection.total_count,
|
|
92
|
+
per_page: collection.limit_value
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def pagination_links(collection)
|
|
97
|
+
base_url = request.base_url + request.path
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
self: build_link_url(base_url, collection.current_page),
|
|
101
|
+
first: build_link_url(base_url, 1),
|
|
102
|
+
prev: collection.prev_page ? build_link_url(base_url, collection.prev_page) : nil,
|
|
103
|
+
next: collection.next_page ? build_link_url(base_url, collection.next_page) : nil,
|
|
104
|
+
last: build_link_url(base_url, collection.total_pages)
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def per_page_param
|
|
111
|
+
per = params[:per_page].to_i
|
|
112
|
+
per = default_per_page if per <= 0
|
|
113
|
+
[per, max_per_page].min
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def default_per_page
|
|
117
|
+
20
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def max_per_page
|
|
121
|
+
100
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_link_url(base_url, page)
|
|
125
|
+
query_params = request.query_parameters.merge(page: page, per_page: per_page_param)
|
|
126
|
+
"#{base_url}?#{query_params.to_query}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Using the Concern
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
135
|
+
module Api
|
|
136
|
+
module V1
|
|
137
|
+
class PostsController < ApplicationController
|
|
138
|
+
include ApiPaginatable
|
|
139
|
+
|
|
140
|
+
def index
|
|
141
|
+
@posts = paginate(Post.order(:created_at))
|
|
142
|
+
|
|
143
|
+
render json: {
|
|
144
|
+
data: @posts,
|
|
145
|
+
meta: pagination_meta(@posts),
|
|
146
|
+
links: pagination_links(@posts)
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Link Header Pagination (GitHub Style)
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# app/controllers/concerns/link_header_pagination.rb
|
|
158
|
+
module LinkHeaderPagination
|
|
159
|
+
extend ActiveSupport::Concern
|
|
160
|
+
|
|
161
|
+
included do
|
|
162
|
+
after_action :set_pagination_headers, only: [:index]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def set_pagination_headers
|
|
166
|
+
return unless @paginated_collection
|
|
167
|
+
|
|
168
|
+
links = []
|
|
169
|
+
links << build_link_header('first', 1)
|
|
170
|
+
links << build_link_header('prev', @paginated_collection.prev_page) if @paginated_collection.prev_page
|
|
171
|
+
links << build_link_header('next', @paginated_collection.next_page) if @paginated_collection.next_page
|
|
172
|
+
links << build_link_header('last', @paginated_collection.total_pages)
|
|
173
|
+
|
|
174
|
+
response.headers['Link'] = links.join(', ')
|
|
175
|
+
response.headers['X-Total-Count'] = @paginated_collection.total_count.to_s
|
|
176
|
+
response.headers['X-Total-Pages'] = @paginated_collection.total_pages.to_s
|
|
177
|
+
response.headers['X-Per-Page'] = @paginated_collection.limit_value.to_s
|
|
178
|
+
response.headers['X-Page'] = @paginated_collection.current_page.to_s
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def build_link_header(rel, page)
|
|
184
|
+
url = url_for(request.query_parameters.merge(page: page, only_path: false))
|
|
185
|
+
%(<#{url}>; rel="#{rel}")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
190
|
+
module Api
|
|
191
|
+
module V1
|
|
192
|
+
class PostsController < ApplicationController
|
|
193
|
+
include LinkHeaderPagination
|
|
194
|
+
|
|
195
|
+
def index
|
|
196
|
+
@paginated_collection = Post.page(params[:page]).per(20)
|
|
197
|
+
render json: @paginated_collection
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Example Response Headers
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
Link: <http://api.example.com/posts?page=1>; rel="first",
|
|
208
|
+
<http://api.example.com/posts?page=2>; rel="prev",
|
|
209
|
+
<http://api.example.com/posts?page=4>; rel="next",
|
|
210
|
+
<http://api.example.com/posts?page=10>; rel="last"
|
|
211
|
+
X-Total-Count: 245
|
|
212
|
+
X-Total-Pages: 10
|
|
213
|
+
X-Per-Page: 25
|
|
214
|
+
X-Page: 3
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Cursor-Based Pagination
|
|
218
|
+
|
|
219
|
+
For real-time feeds and large datasets:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
223
|
+
module Api
|
|
224
|
+
module V1
|
|
225
|
+
class PostsController < ApplicationController
|
|
226
|
+
def index
|
|
227
|
+
@posts = fetch_posts
|
|
228
|
+
|
|
229
|
+
render json: {
|
|
230
|
+
data: @posts,
|
|
231
|
+
meta: cursor_meta(@posts)
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def fetch_posts
|
|
238
|
+
posts = Post.order(id: :desc)
|
|
239
|
+
posts = posts.where('id < ?', params[:before]) if params[:before]
|
|
240
|
+
posts = posts.where('id > ?', params[:after]) if params[:after]
|
|
241
|
+
posts.limit(per_page + 1)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def cursor_meta(posts)
|
|
245
|
+
has_more = posts.size > per_page
|
|
246
|
+
posts = posts.first(per_page) if has_more
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
before: posts.first&.id,
|
|
250
|
+
after: posts.last&.id,
|
|
251
|
+
has_more: has_more
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def per_page
|
|
256
|
+
20
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## JSON:API Specification
|
|
264
|
+
|
|
265
|
+
Following JSON:API pagination spec:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
269
|
+
module Api
|
|
270
|
+
module V1
|
|
271
|
+
class PostsController < ApplicationController
|
|
272
|
+
def index
|
|
273
|
+
@posts = Post.page(params[:page][:number]).per(params[:page][:size] || 20)
|
|
274
|
+
|
|
275
|
+
render json: {
|
|
276
|
+
data: @posts.map { |post| PostSerializer.new(post).as_json },
|
|
277
|
+
links: jsonapi_pagination_links(@posts),
|
|
278
|
+
meta: {
|
|
279
|
+
total: @posts.total_count
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
def jsonapi_pagination_links(collection)
|
|
287
|
+
base_url = request.base_url + request.path
|
|
288
|
+
|
|
289
|
+
{
|
|
290
|
+
self: build_jsonapi_url(base_url, collection.current_page),
|
|
291
|
+
first: build_jsonapi_url(base_url, 1),
|
|
292
|
+
prev: collection.prev_page ? build_jsonapi_url(base_url, collection.prev_page) : nil,
|
|
293
|
+
next: collection.next_page ? build_jsonapi_url(base_url, collection.next_page) : nil,
|
|
294
|
+
last: build_jsonapi_url(base_url, collection.total_pages)
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def build_jsonapi_url(base_url, page_number)
|
|
299
|
+
"#{base_url}?page[number]=#{page_number}&page[size]=#{params.dig(:page, :size) || 20}"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## GraphQL Pagination
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
# app/graphql/types/query_type.rb
|
|
310
|
+
module Types
|
|
311
|
+
class QueryType < Types::BaseObject
|
|
312
|
+
field :posts, Types::PostType.connection_type, null: false do
|
|
313
|
+
argument :page, Integer, required: false, default_value: 1
|
|
314
|
+
argument :per_page, Integer, required: false, default_value: 20
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def posts(page:, per_page:)
|
|
318
|
+
Post.page(page).per([per_page, 100].min)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Performance Optimization for APIs
|
|
325
|
+
|
|
326
|
+
### Without Count for Large Datasets
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
330
|
+
def index
|
|
331
|
+
@posts = Post.page(params[:page]).per(20).without_count
|
|
332
|
+
|
|
333
|
+
render json: {
|
|
334
|
+
data: @posts,
|
|
335
|
+
pagination: {
|
|
336
|
+
current_page: @posts.current_page,
|
|
337
|
+
next_page: @posts.next_page,
|
|
338
|
+
prev_page: @posts.prev_page,
|
|
339
|
+
per_page: @posts.limit_value
|
|
340
|
+
# total_pages and total_count not available
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Conditional Count Query
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
# Only run count query on first page
|
|
350
|
+
def index
|
|
351
|
+
@posts = Post.page(params[:page]).per(20)
|
|
352
|
+
@posts = @posts.without_count unless params[:page].to_i == 1
|
|
353
|
+
|
|
354
|
+
meta = {
|
|
355
|
+
current_page: @posts.current_page,
|
|
356
|
+
per_page: @posts.limit_value
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Only include total counts on first page
|
|
360
|
+
if params[:page].to_i == 1
|
|
361
|
+
meta[:total_pages] = @posts.total_pages
|
|
362
|
+
meta[:total_count] = @posts.total_count
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
render json: {
|
|
366
|
+
data: @posts,
|
|
367
|
+
pagination: meta
|
|
368
|
+
}
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Caching Total Count
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
376
|
+
def index
|
|
377
|
+
@posts = Post.page(params[:page]).per(20)
|
|
378
|
+
|
|
379
|
+
# Cache the total count
|
|
380
|
+
total_count = Rails.cache.fetch(['posts', 'total_count'], expires_in: 5.minutes) do
|
|
381
|
+
Post.count
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
render json: {
|
|
385
|
+
data: @posts,
|
|
386
|
+
pagination: {
|
|
387
|
+
current_page: @posts.current_page,
|
|
388
|
+
total_count: total_count,
|
|
389
|
+
total_pages: (total_count.to_f / 20).ceil
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
## Filtering with Pagination
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
# app/controllers/api/v1/posts_controller.rb
|
|
399
|
+
def index
|
|
400
|
+
@posts = Post.all
|
|
401
|
+
@posts = apply_filters(@posts)
|
|
402
|
+
@posts = @posts.page(params[:page]).per(per_page)
|
|
403
|
+
|
|
404
|
+
render json: {
|
|
405
|
+
data: @posts,
|
|
406
|
+
pagination: pagination_meta(@posts),
|
|
407
|
+
links: pagination_links_with_filters(@posts)
|
|
408
|
+
}
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
private
|
|
412
|
+
|
|
413
|
+
def apply_filters(scope)
|
|
414
|
+
scope = scope.where(category: params[:category]) if params[:category]
|
|
415
|
+
scope = scope.where(status: params[:status]) if params[:status]
|
|
416
|
+
scope = scope.where('created_at > ?', params[:since]) if params[:since]
|
|
417
|
+
scope
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def pagination_links_with_filters(collection)
|
|
421
|
+
base_url = request.base_url + request.path
|
|
422
|
+
filter_params = request.query_parameters.except(:page, :per_page)
|
|
423
|
+
|
|
424
|
+
{
|
|
425
|
+
self: build_filtered_url(base_url, collection.current_page, filter_params),
|
|
426
|
+
first: build_filtered_url(base_url, 1, filter_params),
|
|
427
|
+
prev: collection.prev_page ? build_filtered_url(base_url, collection.prev_page, filter_params) : nil,
|
|
428
|
+
next: collection.next_page ? build_filtered_url(base_url, collection.next_page, filter_params) : nil,
|
|
429
|
+
last: build_filtered_url(base_url, collection.total_pages, filter_params)
|
|
430
|
+
}
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def build_filtered_url(base_url, page, filters)
|
|
434
|
+
query = filters.merge(page: page, per_page: per_page)
|
|
435
|
+
"#{base_url}?#{query.to_query}"
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## Testing API Pagination
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
# spec/requests/api/v1/posts_spec.rb
|
|
443
|
+
require 'rails_helper'
|
|
444
|
+
|
|
445
|
+
RSpec.describe 'Api::V1::Posts', type: :request do
|
|
446
|
+
let!(:posts) { create_list(:post, 30) }
|
|
447
|
+
let(:headers) { { 'Content-Type' => 'application/json' } }
|
|
448
|
+
|
|
449
|
+
describe 'GET /api/v1/posts' do
|
|
450
|
+
it 'returns paginated posts' do
|
|
451
|
+
get '/api/v1/posts', params: { page: 1, per_page: 10 }, headers: headers
|
|
452
|
+
|
|
453
|
+
json = JSON.parse(response.body)
|
|
454
|
+
|
|
455
|
+
expect(response).to have_http_status(:ok)
|
|
456
|
+
expect(json['data'].size).to eq(10)
|
|
457
|
+
expect(json['pagination']['current_page']).to eq(1)
|
|
458
|
+
expect(json['pagination']['total_pages']).to eq(3)
|
|
459
|
+
expect(json['pagination']['total_count']).to eq(30)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
it 'returns second page' do
|
|
463
|
+
get '/api/v1/posts', params: { page: 2, per_page: 10 }, headers: headers
|
|
464
|
+
|
|
465
|
+
json = JSON.parse(response.body)
|
|
466
|
+
|
|
467
|
+
expect(json['data'].size).to eq(10)
|
|
468
|
+
expect(json['pagination']['current_page']).to eq(2)
|
|
469
|
+
expect(json['pagination']['next_page']).to eq(3)
|
|
470
|
+
expect(json['pagination']['prev_page']).to eq(1)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
it 'returns empty array for out of range page' do
|
|
474
|
+
get '/api/v1/posts', params: { page: 999 }, headers: headers
|
|
475
|
+
|
|
476
|
+
json = JSON.parse(response.body)
|
|
477
|
+
|
|
478
|
+
expect(json['data']).to be_empty
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
it 'respects per_page parameter' do
|
|
482
|
+
get '/api/v1/posts', params: { per_page: 5 }, headers: headers
|
|
483
|
+
|
|
484
|
+
json = JSON.parse(response.body)
|
|
485
|
+
|
|
486
|
+
expect(json['data'].size).to eq(5)
|
|
487
|
+
expect(json['pagination']['per_page']).to eq(5)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it 'enforces maximum per_page' do
|
|
491
|
+
get '/api/v1/posts', params: { per_page: 999 }, headers: headers
|
|
492
|
+
|
|
493
|
+
json = JSON.parse(response.body)
|
|
494
|
+
|
|
495
|
+
expect(json['pagination']['per_page']).to eq(100) # Max limit
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
it 'includes pagination links' do
|
|
499
|
+
get '/api/v1/posts', params: { page: 2 }, headers: headers
|
|
500
|
+
|
|
501
|
+
json = JSON.parse(response.body)
|
|
502
|
+
|
|
503
|
+
expect(json['links']['first']).to be_present
|
|
504
|
+
expect(json['links']['prev']).to be_present
|
|
505
|
+
expect(json['links']['next']).to be_present
|
|
506
|
+
expect(json['links']['last']).to be_present
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## Best Practices
|
|
513
|
+
|
|
514
|
+
1. **Always set max per_page**: Prevent abuse with reasonable limits (e.g., 100)
|
|
515
|
+
2. **Include pagination metadata**: Help clients understand available data
|
|
516
|
+
3. **Provide navigation links**: Make it easy to traverse pages
|
|
517
|
+
4. **Use Link headers for simple APIs**: Keep response body clean
|
|
518
|
+
5. **Cache total counts**: Expensive on large tables
|
|
519
|
+
6. **Consider cursor pagination**: Better for real-time feeds
|
|
520
|
+
7. **Document pagination**: Clear API docs with examples
|
|
521
|
+
8. **Handle out of range gracefully**: Return empty array, not errors
|
|
522
|
+
9. **Preserve query parameters**: Keep filters/sorts in pagination links
|
|
523
|
+
10. **Use consistent format**: Pick one pagination style and stick with it
|