Skip to content

Commit 90e40e1

Browse files
dadachiclaude
andcommitted
add pagination to item_tags index API with backward compatibility
- Create pagy initializer with default limit 20 - Include Pagy::Method in shopkeeper base controller with pagy_meta helper - Paginate item_tags index: limit 20 with page param, 1000 without (backward compat) - Add meta object (current_page, total_pages, total_count, limit) to response - Add page query param and meta schema to openapi.yaml - Add pagination-item-tags.md design doc - Add pagination tests: meta presence, backward compat, paginated, overflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3375b56 commit 90e40e1

6 files changed

Lines changed: 235 additions & 1 deletion

File tree

app/controllers/api/v1/shopkeeper/base_controller.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ class Api::V1::Shopkeeper::BaseController < ApplicationController
33
include SetCurrentRequestDetails
44
include Pundit::Authorization
55
include CurrentShopkeeperHelper
6+
include Pagy::Method
67

78
before_action :authenticate_shopkeeper!
89
after_action :verify_authorized
@@ -34,4 +35,13 @@ def render_error(code:, message:, status:)
3435
def user_not_authorized
3536
render_error(code: 401, message: I18n.t("unauthorized"), status: :unauthorized)
3637
end
38+
39+
def pagy_meta(pagy)
40+
{
41+
current_page: pagy.page,
42+
total_pages: pagy.pages,
43+
total_count: pagy.count,
44+
limit: pagy.limit
45+
}
46+
end
3747
end

app/controllers/api/v1/shopkeeper/item_tags_controller.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ class Api::V1::Shopkeeper::ItemTagsController < Api::V1::Shopkeeper::BaseControl
55
def index
66
authorize ItemTag
77

8-
@item_tags = @shop.item_tags.order(queue_number: :asc).includes(:shop)
8+
@pagy, @item_tags = pagy(
9+
@shop.item_tags.order(queue_number: :asc).includes(:shop),
10+
limit: params[:page].present? ? Pagy::OPTIONS[:limit] : 1000
11+
)
912

1013
options = {}
1114
options[:include] = [:shop]
15+
options[:meta] = pagy_meta(@pagy)
1216
render json: ItemTagSerializer.new(@item_tags, options).serializable_hash
1317
end
1418

config/initializers/pagy.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Pagy::OPTIONS[:limit] = 20

docs/openapi.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,14 @@ paths:
12751275
operationId: listItemTags
12761276
summary: List item tags for a shop
12771277
tags: [Item Tags]
1278+
parameters:
1279+
- name: page
1280+
in: query
1281+
required: false
1282+
description: Page number. When absent, returns up to 1000 items (backward compat).
1283+
schema:
1284+
type: integer
1285+
minimum: 1
12781286
responses:
12791287
'200':
12801288
description: Item tag list
@@ -1291,6 +1299,17 @@ paths:
12911299
type: array
12921300
items:
12931301
$ref: '#/components/schemas/Shop'
1302+
meta:
1303+
type: object
1304+
properties:
1305+
current_page:
1306+
type: integer
1307+
total_pages:
1308+
type: integer
1309+
total_count:
1310+
type: integer
1311+
limit:
1312+
type: integer
12941313
'401':
12951314
$ref: '#/components/responses/Unauthorized'
12961315

docs/pagination-item-tags.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Pagination for ItemTags Index API
2+
3+
## Context
4+
5+
The `GET /api/v1/shopkeeper/shops/{shop_id}/item_tags` endpoint currently returns all item tags without pagination. Adding Pagy pagination with backward-compatible behavior so existing clients continue working.
6+
7+
## Current State
8+
9+
- **Pagy 43** already installed and configured (`config/initializers/pagy.rb`: default limit 20)
10+
- **`Pagy::Method`** already included in `Display::BaseController` — not yet in the shopkeeper API base controller
11+
- **Response format:** JSON:API via `jsonapi-serializer` gem (`{ data: [...], included: [...] }`)
12+
- **Neither iOS nor Android** clients send pagination params or parse pagination metadata
13+
14+
## API Changes
15+
16+
### Request
17+
18+
New optional query parameter:
19+
- `page` (integer) — page number, defaults to 1
20+
21+
When `page` param is present, returns 20 items per page (Pagy default).
22+
When `page` param is absent, returns up to 1000 items (backward compat — remove once clients are updated).
23+
24+
### Response
25+
26+
New `meta` key added to top-level JSON:API response:
27+
28+
```json
29+
{
30+
"data": [...],
31+
"included": [...],
32+
"meta": {
33+
"current_page": 1,
34+
"total_pages": 3,
35+
"total_count": 55,
36+
"limit": 20
37+
}
38+
}
39+
```
40+
41+
## Backend Implementation (Rails API)
42+
43+
### Files to modify
44+
45+
1. **`app/controllers/api/v1/shopkeeper/base_controller.rb`**
46+
- Add `include Pagy::Backend`
47+
- Add private `pagy_meta(pagy)` helper
48+
49+
2. **`app/controllers/api/v1/shopkeeper/item_tags_controller.rb`**
50+
- Update `index` action to use `pagy()` with backward-compat limit logic
51+
- Add `meta` option to serializer
52+
53+
3. **`test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb`**
54+
- Test pagination meta presence
55+
- Test pagination with explicit page param
56+
- Test overflow returns empty data
57+
- Test backward compat (no page param returns large limit)
58+
59+
4. **`docs/openapi.yaml`**
60+
- Add `page` query parameter to item_tags index
61+
- Add `meta` object to response schema
62+
63+
### Code changes
64+
65+
**base_controller.rb** — add after existing includes:
66+
```ruby
67+
include Pagy::Method
68+
69+
# in private section:
70+
def pagy_meta(pagy)
71+
{
72+
current_page: pagy.page,
73+
total_pages: pagy.pages,
74+
total_count: pagy.count,
75+
limit: pagy.limit
76+
}
77+
end
78+
```
79+
80+
**item_tags_controller.rb** — replace index:
81+
```ruby
82+
def index
83+
authorize ItemTag
84+
85+
@pagy, @item_tags = pagy(
86+
@shop.item_tags.order(queue_number: :asc).includes(:shop),
87+
limit: params[:page].present? ? Pagy::OPTIONS[:limit] : 1000
88+
)
89+
90+
options = {}
91+
options[:include] = [:shop]
92+
options[:meta] = pagy_meta(@pagy)
93+
render json: ItemTagSerializer.new(@item_tags, options).serializable_hash
94+
end
95+
```
96+
97+
## iOS Client Changes
98+
99+
### Usage of `GET /shops/{shop_id}/item_tags`
100+
101+
This endpoint is used in two places:
102+
1. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`** — item tag management list (should paginate)
103+
2. **`UI/Shop Detail/ShopDetailView.swift`** — shop overview (should retrieve all item_tags, no `page` param)
104+
105+
ShopDetailView should continue calling without `page` param to get all items (backward-compat limit 1000). Only ItemTagListView should send `page` param for paginated results.
106+
107+
### Files to modify
108+
109+
1. **`Networking/Requests/ItemTagsRequest.swift`**`GetItemTagsRequest`
110+
- Add optional `page` query parameter
111+
112+
2. **`Networking/JSONAPI/JSONAPIDocument.swift`** (or create `PaginationMeta`)
113+
- Parse `meta` from response into a pagination struct
114+
115+
3. **`Models/PaginationMeta.swift`** (new)
116+
- Struct: `currentPage`, `totalPages`, `totalCount`, `limit`
117+
118+
4. **`Data/Repositories/ItemTagRepository.swift`**
119+
- Update `reload(shopId:)` to accept optional page param
120+
- Store pagination meta alongside item tags
121+
- Add `loadMore(shopId:)` or `loadPage(shopId:page:)` method
122+
123+
5. **`UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift`**
124+
- Implement "load more" or infinite scroll logic
125+
- Track current page and whether more pages exist
126+
127+
6. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`**
128+
- Add scroll-to-bottom trigger for loading next page
129+
- Show loading indicator during pagination
130+
131+
7. **`UI/Shop Detail/ShopDetailView.swift`** (or its ViewModel)
132+
- No changes needed — continue calling without `page` param to get all items
133+
134+
## Android Client Changes
135+
136+
### Files to modify
137+
138+
1. **`data/item_tag/ItemTagApi.kt`**
139+
- Add `@Query("page") page: Int?` parameter to `getItemTags()`
140+
141+
2. **`data/item_tag/model/Meta.kt`** (or new `PaginationMeta.kt`)
142+
- Parse pagination fields from `meta` object (already has a `Meta` class — may need to add pagination fields)
143+
144+
3. **`data/item_tag/ItemTagRepositoryImpl.kt`**
145+
- Accept page parameter in fetch methods
146+
- Store pagination state
147+
148+
4. **`ui/shop_settings/item_tag_list/ItemTagListViewModel.kt`**
149+
- Implement pagination state management
150+
- Add `loadMore()` function
151+
152+
5. **`ui/shop_settings/item_tag_list/ItemTagListScreen.kt`** (or equivalent composable)
153+
- Add infinite scroll / load more UI
154+
155+
## Migration Strategy
156+
157+
1. Deploy API with backward-compat (large limit when no page param) — **do this first**
158+
2. Update iOS and Android clients:
159+
- ItemTagListView/Screen: send `page` param and handle `meta` for pagination
160+
- ShopDetailView/Screen: keep calling without `page` param (gets all items)
161+
3. The backward-compat large limit should remain long-term since ShopDetailView needs all items

test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,45 @@ class Api::V1::Shopkeeper::ItemTagsControllerTest < ActionDispatch::IntegrationT
1616
assert_includes response.parsed_body["data"].map { |t| t["attributes"]["queue_number"] }, @item_tag.queue_number
1717
end
1818

19+
test "index returns pagination meta" do
20+
get api_v1_shopkeeper_shop_item_tags_url(@shop), headers: @shopkeeper.create_new_auth_token
21+
assert_response :success
22+
23+
meta = response.parsed_body["meta"]
24+
assert_not_nil meta
25+
assert_equal 1, meta["current_page"]
26+
assert_equal @shop.item_tags.count, meta["total_count"]
27+
assert meta["total_pages"].present?
28+
assert meta["limit"].present?
29+
end
30+
31+
test "index without page param returns up to 1000 items for backward compat" do
32+
get api_v1_shopkeeper_shop_item_tags_url(@shop), headers: @shopkeeper.create_new_auth_token
33+
assert_response :success
34+
35+
meta = response.parsed_body["meta"]
36+
assert_equal 1000, meta["limit"]
37+
assert_equal @shop.item_tags.count, response.parsed_body["data"].size
38+
end
39+
40+
test "index with page param paginates with default limit" do
41+
get api_v1_shopkeeper_shop_item_tags_url(@shop, page: 1), headers: @shopkeeper.create_new_auth_token
42+
assert_response :success
43+
44+
meta = response.parsed_body["meta"]
45+
assert_equal Pagy::OPTIONS[:limit], meta["limit"]
46+
assert_equal 1, meta["current_page"]
47+
end
48+
49+
test "index with page param beyond last page returns empty data" do
50+
get api_v1_shopkeeper_shop_item_tags_url(@shop, page: 9999), headers: @shopkeeper.create_new_auth_token
51+
assert_response :success
52+
53+
assert_empty response.parsed_body["data"]
54+
meta = response.parsed_body["meta"]
55+
assert_equal 9999, meta["current_page"]
56+
end
57+
1958
test "index requires authentication" do
2059
get api_v1_shopkeeper_shop_item_tags_url(@shop)
2160
assert_response :unauthorized

0 commit comments

Comments
 (0)