From 90e40e1e6beb26c558a5d64d970c489c08871a49 Mon Sep 17 00:00:00 2001 From: dadachi Date: Fri, 3 Apr 2026 10:14:32 +0900 Subject: [PATCH] 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) --- .../api/v1/shopkeeper/base_controller.rb | 10 ++ .../api/v1/shopkeeper/item_tags_controller.rb | 6 +- config/initializers/pagy.rb | 1 + docs/openapi.yaml | 19 +++ docs/pagination-item-tags.md | 161 ++++++++++++++++++ .../shopkeeper/item_tags_controller_test.rb | 39 +++++ 6 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 config/initializers/pagy.rb create mode 100644 docs/pagination-item-tags.md diff --git a/app/controllers/api/v1/shopkeeper/base_controller.rb b/app/controllers/api/v1/shopkeeper/base_controller.rb index cc9acb8..fae21a0 100644 --- a/app/controllers/api/v1/shopkeeper/base_controller.rb +++ b/app/controllers/api/v1/shopkeeper/base_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Shopkeeper::BaseController < ApplicationController include SetCurrentRequestDetails include Pundit::Authorization include CurrentShopkeeperHelper + include Pagy::Method before_action :authenticate_shopkeeper! after_action :verify_authorized @@ -34,4 +35,13 @@ def render_error(code:, message:, status:) def user_not_authorized render_error(code: 401, message: I18n.t("unauthorized"), status: :unauthorized) end + + def pagy_meta(pagy) + { + current_page: pagy.page, + total_pages: pagy.pages, + total_count: pagy.count, + limit: pagy.limit + } + end end diff --git a/app/controllers/api/v1/shopkeeper/item_tags_controller.rb b/app/controllers/api/v1/shopkeeper/item_tags_controller.rb index 4669668..ee1b67d 100644 --- a/app/controllers/api/v1/shopkeeper/item_tags_controller.rb +++ b/app/controllers/api/v1/shopkeeper/item_tags_controller.rb @@ -5,10 +5,14 @@ class Api::V1::Shopkeeper::ItemTagsController < Api::V1::Shopkeeper::BaseControl def index authorize ItemTag - @item_tags = @shop.item_tags.order(queue_number: :asc).includes(:shop) + @pagy, @item_tags = pagy( + @shop.item_tags.order(queue_number: :asc).includes(:shop), + limit: params[:page].present? ? Pagy::OPTIONS[:limit] : 1000 + ) options = {} options[:include] = [:shop] + options[:meta] = pagy_meta(@pagy) render json: ItemTagSerializer.new(@item_tags, options).serializable_hash end diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 0000000..8771f1e --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1 @@ +Pagy::OPTIONS[:limit] = 20 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0877219..9397bcc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1275,6 +1275,14 @@ paths: operationId: listItemTags summary: List item tags for a shop tags: [Item Tags] + parameters: + - name: page + in: query + required: false + description: Page number. When absent, returns up to 1000 items (backward compat). + schema: + type: integer + minimum: 1 responses: '200': description: Item tag list @@ -1291,6 +1299,17 @@ paths: type: array items: $ref: '#/components/schemas/Shop' + meta: + type: object + properties: + current_page: + type: integer + total_pages: + type: integer + total_count: + type: integer + limit: + type: integer '401': $ref: '#/components/responses/Unauthorized' diff --git a/docs/pagination-item-tags.md b/docs/pagination-item-tags.md new file mode 100644 index 0000000..38905c1 --- /dev/null +++ b/docs/pagination-item-tags.md @@ -0,0 +1,161 @@ +# Pagination for ItemTags Index API + +## Context + +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. + +## Current State + +- **Pagy 43** already installed and configured (`config/initializers/pagy.rb`: default limit 20) +- **`Pagy::Method`** already included in `Display::BaseController` — not yet in the shopkeeper API base controller +- **Response format:** JSON:API via `jsonapi-serializer` gem (`{ data: [...], included: [...] }`) +- **Neither iOS nor Android** clients send pagination params or parse pagination metadata + +## API Changes + +### Request + +New optional query parameter: +- `page` (integer) — page number, defaults to 1 + +When `page` param is present, returns 20 items per page (Pagy default). +When `page` param is absent, returns up to 1000 items (backward compat — remove once clients are updated). + +### Response + +New `meta` key added to top-level JSON:API response: + +```json +{ + "data": [...], + "included": [...], + "meta": { + "current_page": 1, + "total_pages": 3, + "total_count": 55, + "limit": 20 + } +} +``` + +## Backend Implementation (Rails API) + +### Files to modify + +1. **`app/controllers/api/v1/shopkeeper/base_controller.rb`** + - Add `include Pagy::Backend` + - Add private `pagy_meta(pagy)` helper + +2. **`app/controllers/api/v1/shopkeeper/item_tags_controller.rb`** + - Update `index` action to use `pagy()` with backward-compat limit logic + - Add `meta` option to serializer + +3. **`test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb`** + - Test pagination meta presence + - Test pagination with explicit page param + - Test overflow returns empty data + - Test backward compat (no page param returns large limit) + +4. **`docs/openapi.yaml`** + - Add `page` query parameter to item_tags index + - Add `meta` object to response schema + +### Code changes + +**base_controller.rb** — add after existing includes: +```ruby +include Pagy::Method + +# in private section: +def pagy_meta(pagy) + { + current_page: pagy.page, + total_pages: pagy.pages, + total_count: pagy.count, + limit: pagy.limit + } +end +``` + +**item_tags_controller.rb** — replace index: +```ruby +def index + authorize ItemTag + + @pagy, @item_tags = pagy( + @shop.item_tags.order(queue_number: :asc).includes(:shop), + limit: params[:page].present? ? Pagy::OPTIONS[:limit] : 1000 + ) + + options = {} + options[:include] = [:shop] + options[:meta] = pagy_meta(@pagy) + render json: ItemTagSerializer.new(@item_tags, options).serializable_hash +end +``` + +## iOS Client Changes + +### Usage of `GET /shops/{shop_id}/item_tags` + +This endpoint is used in two places: +1. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`** — item tag management list (should paginate) +2. **`UI/Shop Detail/ShopDetailView.swift`** — shop overview (should retrieve all item_tags, no `page` param) + +ShopDetailView should continue calling without `page` param to get all items (backward-compat limit 1000). Only ItemTagListView should send `page` param for paginated results. + +### Files to modify + +1. **`Networking/Requests/ItemTagsRequest.swift`** — `GetItemTagsRequest` + - Add optional `page` query parameter + +2. **`Networking/JSONAPI/JSONAPIDocument.swift`** (or create `PaginationMeta`) + - Parse `meta` from response into a pagination struct + +3. **`Models/PaginationMeta.swift`** (new) + - Struct: `currentPage`, `totalPages`, `totalCount`, `limit` + +4. **`Data/Repositories/ItemTagRepository.swift`** + - Update `reload(shopId:)` to accept optional page param + - Store pagination meta alongside item tags + - Add `loadMore(shopId:)` or `loadPage(shopId:page:)` method + +5. **`UI/Shop Settings/ItemTag List/ItemTagListViewModel.swift`** + - Implement "load more" or infinite scroll logic + - Track current page and whether more pages exist + +6. **`UI/Shop Settings/ItemTag List/ItemTagListView.swift`** + - Add scroll-to-bottom trigger for loading next page + - Show loading indicator during pagination + +7. **`UI/Shop Detail/ShopDetailView.swift`** (or its ViewModel) + - No changes needed — continue calling without `page` param to get all items + +## Android Client Changes + +### Files to modify + +1. **`data/item_tag/ItemTagApi.kt`** + - Add `@Query("page") page: Int?` parameter to `getItemTags()` + +2. **`data/item_tag/model/Meta.kt`** (or new `PaginationMeta.kt`) + - Parse pagination fields from `meta` object (already has a `Meta` class — may need to add pagination fields) + +3. **`data/item_tag/ItemTagRepositoryImpl.kt`** + - Accept page parameter in fetch methods + - Store pagination state + +4. **`ui/shop_settings/item_tag_list/ItemTagListViewModel.kt`** + - Implement pagination state management + - Add `loadMore()` function + +5. **`ui/shop_settings/item_tag_list/ItemTagListScreen.kt`** (or equivalent composable) + - Add infinite scroll / load more UI + +## Migration Strategy + +1. Deploy API with backward-compat (large limit when no page param) — **do this first** +2. Update iOS and Android clients: + - ItemTagListView/Screen: send `page` param and handle `meta` for pagination + - ShopDetailView/Screen: keep calling without `page` param (gets all items) +3. The backward-compat large limit should remain long-term since ShopDetailView needs all items diff --git a/test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb b/test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb index 36b9e06..26a1b0a 100644 --- a/test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb +++ b/test/controllers/api/v1/shopkeeper/item_tags_controller_test.rb @@ -16,6 +16,45 @@ class Api::V1::Shopkeeper::ItemTagsControllerTest < ActionDispatch::IntegrationT assert_includes response.parsed_body["data"].map { |t| t["attributes"]["queue_number"] }, @item_tag.queue_number end + test "index returns pagination meta" do + get api_v1_shopkeeper_shop_item_tags_url(@shop), headers: @shopkeeper.create_new_auth_token + assert_response :success + + meta = response.parsed_body["meta"] + assert_not_nil meta + assert_equal 1, meta["current_page"] + assert_equal @shop.item_tags.count, meta["total_count"] + assert meta["total_pages"].present? + assert meta["limit"].present? + end + + test "index without page param returns up to 1000 items for backward compat" do + get api_v1_shopkeeper_shop_item_tags_url(@shop), headers: @shopkeeper.create_new_auth_token + assert_response :success + + meta = response.parsed_body["meta"] + assert_equal 1000, meta["limit"] + assert_equal @shop.item_tags.count, response.parsed_body["data"].size + end + + test "index with page param paginates with default limit" do + get api_v1_shopkeeper_shop_item_tags_url(@shop, page: 1), headers: @shopkeeper.create_new_auth_token + assert_response :success + + meta = response.parsed_body["meta"] + assert_equal Pagy::OPTIONS[:limit], meta["limit"] + assert_equal 1, meta["current_page"] + end + + test "index with page param beyond last page returns empty data" do + get api_v1_shopkeeper_shop_item_tags_url(@shop, page: 9999), headers: @shopkeeper.create_new_auth_token + assert_response :success + + assert_empty response.parsed_body["data"] + meta = response.parsed_body["meta"] + assert_equal 9999, meta["current_page"] + end + test "index requires authentication" do get api_v1_shopkeeper_shop_item_tags_url(@shop) assert_response :unauthorized