Pagination contract is not standardized across endpoints

This commit is contained in:
2026-04-28 21:40:22 +02:00
parent ad21590f60
commit a2129bb9bd
13 changed files with 387 additions and 56 deletions

View File

@@ -461,6 +461,64 @@ class BansByCountryResponse(BaseModel):
5. **No ad-hoc wrapper objects** — Don't invent new response shapes. Use the patterns above.
### Standardized Pagination Query Parameters
All paginated endpoints follow a consistent query parameter contract:
| Parameter | Type | Constraints | Default | Notes |
|---|---|---|---|---|
| `page` | int | ≥ 1 | `1` | 1-based page number (not 0-based offset). |
| `page_size` | int | 1500 | `100` | Items per page. Clients may request smaller pages for UI reasons. |
**Implementation:**
```python
from fastapi import Query
from app.utils.constants import DEFAULT_PAGE_SIZE
@router.get("/items")
async def get_items(
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(
default=DEFAULT_PAGE_SIZE,
ge=1,
le=500,
description="Items per page (max 500).",
),
):
# Compute offset for database query
offset = (page - 1) * page_size
items = await db.fetch("SELECT * FROM items LIMIT ? OFFSET ?", page_size, offset)
total = await db.fetchval("SELECT COUNT(*) FROM items")
return PaginatedListResponse(
items=items,
total=total,
page=page,
page_size=page_size,
)
```
**Helper functions** are available in `app.utils.pagination`:
```python
from app.utils.pagination import get_offset, compute_total_pages
# Calculate database offset from page and page_size
offset = get_offset(page, page_size) # Equivalent to (page - 1) * page_size
# Calculate total pages for rendering pagination UI (optional)
total_pages = compute_total_pages(total, page_size)
```
**Rules:**
1. **Use 1-based pages** — Not 0-based offsets. Page 1 is always the first page.
2. **Always provide defaults** — Use `DEFAULT_PAGE_SIZE` (100) and initial page 1.
3. **Cap maximum page_size at 500** — Prevents accidental DoS from enormous requests.
4. **Respond with `PaginatedListResponse[T]`** — Must include `items`, `total`, `page`, `page_size`.
5. **Never include `total_pages` in responses** — The frontend can calculate it as `Math.ceil(total / page_size)`.
---
## 5. Pydantic Models

View File

@@ -1,22 +1,3 @@
## 25) No canonical snake_case/camelCase serialization policy
- Where found:
- [backend/app/models/server.py](backend/app/models/server.py)
- [frontend/src/types/server.ts](frontend/src/types/server.ts)
- Why this is needed:
- Naming convention drift causes brittle frontend-backend contracts.
- Goal:
- Enforce one API field naming policy.
- What to do:
- Configure model aliasing strategy and update frontend contracts.
- Possible traps and issues:
- Partial migration can leave mixed payload formats.
- Docs changes needed:
- Add naming convention section for API fields.
- Doc references:
- [Docs/Backend-Development.md](Docs/Backend-Development.md)
- https://docs.pydantic.dev/latest/concepts/alias/
---
## 26) Pagination contract is not standardized across endpoints
- Where found:

View File

@@ -246,6 +246,99 @@ const doBan = useCallback(
- ❌ Passing API functions directly to components — couples components to API contract
- ❌ Multiple domain hooks for the same data without deduplication — causes wasted requests and state desync
### Standardized Pagination
All paginated endpoints follow a consistent contract to simplify frontend logic and reduce boilerplate:
**Pagination Response Type:**
```ts
// types/response.ts
export interface PaginatedListResponse<T> extends CollectionResponse<T> {
/** Current page number (1-based). */
page: number;
/** Number of items per page. */
page_size: number;
}
// Extending interfaces for specific data types:
// types/ban.ts
export interface DashboardBanListResponse extends PaginatedListResponse<DashboardBanItem> {}
// types/history.ts
export interface HistoryListResponse extends PaginatedListResponse<HistoryBanItem> {}
// types/blocklist.ts
export interface ImportLogListResponse extends PaginatedListResponse<ImportLogEntry> {}
```
**Query Parameters:**
All paginated endpoints accept these standardized parameters:
| Parameter | Type | Range | Default |
|---|---|---|---|
| `page` | number | ≥ 1 | `1` |
| `page_size` | number | 1500 | `100` |
**Frontend Calculation of Total Pages:**
Frontend code should calculate total pages from the response without relying on a `total_pages` field:
```ts
// Compute the total number of pages
const totalPages = Math.ceil(response.total / response.page_size);
const isLastPage = response.page >= totalPages;
const isFirstPage = response.page === 1;
// Determine next/previous pages (with bounds checking)
const nextPage = isLastPage ? response.page : response.page + 1;
const prevPage = isFirstPage ? 1 : response.page - 1;
```
**Example Hook:**
```ts
// hooks/useHistoryPagination.ts
export function useHistoryPagination() {
const [page, setPage] = useState(1);
const pageSize = 100;
const fetcher = useCallback(
(signal: AbortSignal) =>
fetchHistory(
{ page, pageSize, /* filters */ },
signal,
),
[page, pageSize],
);
const { items: historyItems, data: fullResponse, loading, error, refresh } = useListData({
fetcher,
selector: (res: HistoryListResponse) => res.items,
});
// Calculate pagination UI state
const totalPages = Math.ceil((fullResponse?.total ?? 0) / pageSize);
const canGoPrevious = page > 1;
const canGoNext = page < totalPages;
return {
historyItems,
page,
pageSize,
total: fullResponse?.total ?? 0,
totalPages,
canGoPrevious,
canGoNext,
setPage: (newPage: number) => setPage(Math.max(1, Math.min(newPage, totalPages))),
loading,
error,
refresh,
};
}
```
### Hook Architecture & Reusable Primitives
BanGUI's hooks are built on composable primitives to eliminate duplication and enforce consistent patterns.