Files
BanGUI/backend/app/models/response.py
Lukas 67b26a3ef7 Refactor pagination with cursor-based support and standardized response format
- Implement cursor-based pagination in pagination.py
- Update response models to standardize pagination structure
- Add cursor pagination utilities for repositories
- Update HistoryArchiveRepository and ImportLogRepository with new pagination
- Add comprehensive tests for cursor pagination
- Update documentation for backend development and task tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 17:54:05 +02:00

329 lines
11 KiB
Python

"""Base response wrapper models for standardized API envelopes.
All API endpoints should wrap their responses using the base classes defined here.
This ensures a consistent response shape across the entire API, reducing frontend
branching logic and integration bugs.
Response Patterns:
1. **Paginated List** — Use `PaginatedListResponse[T]` for endpoints returning paginated items.
Example: GET /api/jails, GET /api/dashboard/bans
```python
class MyListResponse(PaginatedListResponse[MyItem]):
pass
# Returns:
{
"items": [...],
"pagination": {
"page": 1,
"page_size": 20,
"total": 100,
"total_pages": 5,
"has_next_page": true,
"has_prev_page": false
}
}
```
2. **Simple Collection** — Use `CollectionResponse[T]` for non-paginated collections.
Example: GET /api/bans/active
```python
class MyCollectionResponse(CollectionResponse[MyItem]):
pass
# Returns:
{
"items": [...],
"total": 50
}
```
3. **Single Item Detail** — Use domain model directly wrapped in a named field.
Example: GET /api/jails/{name}, GET /api/dashboard/status
```python
class MyDetailResponse(BaseModel):
jail: Jail # or: status: ServerStatus, settings: ServerSettings
# Optional extra fields (ignore_list, warnings, etc.)
# Returns:
{
"jail": {...},
"ignore_list": [...]
}
```
4. **Command/Action Result** — Use `CommandResponse` for success/acknowledgement.
Example: POST /api/jails/{name}/start, POST /api/bans
```python
class MyCommandResponse(CommandResponse):
jail: str # Optional: target identifier
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd"
}
```
5. **Aggregated Data** — Use domain-specific aggregation models with metadata.
Example: GET /api/dashboard/bans/by-jail
```python
class MyAggregationResponse(BaseModel):
jails: list[JailBanCount] # or: countries, buckets, etc.
total: int
# Optional: filters, time_range metadata
# Returns:
{
"jails": [...],
"total": 1234
}
```
Note on field naming:
- Paginated/collection responses always use "items" for the data array.
- Detail responses use domain-specific field names (jail, status, settings).
- Aggregation responses use domain-specific field names (jails, countries, buckets).
- All responses with multiple items include a "total" field.
"""
from typing import Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field
T = TypeVar("T")
class BanGuiBaseModel(BaseModel):
"""Project-wide Pydantic base model.
Enforces the canonical **snake_case** API field naming policy:
all JSON wire-format field names use ``snake_case`` on both the backend
(Python) and the frontend (TypeScript interfaces). No ``alias_generator``
is applied — field names are serialized exactly as written.
Rules:
- Every model in ``app/models/`` must inherit from this class.
- Field names must be ``snake_case`` in Python *and* in the JSON payload.
- The corresponding TypeScript interface fields must also be ``snake_case``.
- Never add a ``camelCase`` alias generator to individual models — any
serialization change must go through this base class so all models
update at once.
"""
model_config = ConfigDict(strict=True)
class PaginationMetadata(BanGuiBaseModel):
"""Pagination metadata embedded in paginated list responses.
Contains page information and computed fields to support frontend pagination controls.
Supports both offset-based and cursor-based pagination modes.
Fields:
page: Current page number (1-based). Set to 1 for cursor pagination.
page_size: Number of items per page.
total: Total number of items matching the query (across all pages).
For cursor pagination, this is -1 (unknown without full scan).
total_pages: Computed total number of pages.
For cursor pagination, this is -1 (unknown without full scan).
has_next_page: Whether there is a next page after this one.
has_prev_page: Whether there is a previous page before this one.
Always False for cursor pagination (cannot navigate backward without storing history).
cursor: Opaque cursor token for fetching the next page (cursor pagination only).
None for offset pagination or when there are no more pages.
Example (offset pagination):
```python
pagination = PaginationMetadata(
page=2,
page_size=50,
total=150,
total_pages=3,
has_next_page=True,
has_prev_page=True,
cursor=None
)
```
Example (cursor pagination):
```python
pagination = PaginationMetadata(
page=1,
page_size=50,
total=-1,
total_pages=-1,
has_next_page=True,
has_prev_page=False,
cursor="eyJpZCI6IDQyN30="
)
```
"""
page: int = Field(..., ge=1, description="Current page number (1-based). Set to 1 for cursor pagination.")
page_size: int = Field(..., ge=1, description="Number of items per page.")
total: int = Field(..., description="Total number of items matching the query. -1 if unknown (cursor pagination).")
total_pages: int = Field(..., description="Computed total number of pages. -1 if unknown (cursor pagination).")
has_next_page: bool = Field(..., description="Whether there is a next page after this one.")
has_prev_page: bool = Field(..., description="Whether there is a previous page before this one.")
cursor: str | None = Field(
default=None,
description="Opaque cursor token for fetching the next page (cursor pagination only).",
)
class PaginatedListResponse(BanGuiBaseModel, Generic[T]):
"""Standardized paginated list response.
Use this as a base for all endpoints that return paginated collections.
Automatically includes pagination metadata to support frontend paging UIs.
Fields:
items: The data items for the current page.
pagination: Pagination metadata with computed derived fields.
Example:
```python
class UserListResponse(PaginatedListResponse[User]):
pass
# Returns:
{
"items": [...],
"pagination": {
"page": 2,
"page_size": 50,
"total": 150,
"total_pages": 3,
"has_next_page": true,
"has_prev_page": true
}
}
```
"""
items: list[T] = Field(default_factory=list, description="Data items for the current page.")
pagination: PaginationMetadata = Field(..., description="Pagination metadata with computed derived fields.")
class CollectionResponse(BanGuiBaseModel, Generic[T]):
"""Standardized non-paginated collection response.
Use this for endpoints that return a collection without pagination support.
Simpler than PaginatedListResponse, but still provides consistent wrapping.
Fields:
items: The data items in the collection.
total: Total number of items.
Example:
```python
class ActiveBansResponse(CollectionResponse[ActiveBan]):
pass
# Returns:
{
"items": [...],
"total": 42
}
```
"""
items: list[T] = Field(default_factory=list, description="Collection items.")
total: int = Field(..., ge=0, description="Total number of items.")
class CommandResponse(BanGuiBaseModel):
"""Standardized command/action result response.
Use this for endpoints that execute commands (start, stop, reload, ban, unban, etc.).
Always includes a success indicator and human-readable message.
Fields:
message: Human-readable result message or error description.
success: Whether the command succeeded (default True).
Example:
```python
class StartJailResponse(CommandResponse):
jail: str # Optional: target identifier
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd"
}
```
"""
message: str = Field(..., description="Human-readable result or error message.")
success: bool = Field(
default=True,
description="Whether the command succeeded (false for errors in non-exception handlers).",
)
class ErrorResponse(BanGuiBaseModel):
"""Standardized error response envelope for all API errors.
Use this for all error responses to ensure consistent client-side error handling.
The error code enables machine-readable branching, while detail provides
human-readable context. Metadata offers optional structured context.
The correlation_id field enables tracing this error back through logs on both
frontend and backend, enabling correlation across distributed systems.
Fields:
code: Machine-readable error code (e.g., "jail_not_found", "invalid_input").
detail: Human-readable error description for display to users.
metadata: Optional structured context (e.g., field names, constraint violations).
correlation_id: Unique ID for correlating this error with request logs.
Example:
```python
# 404 Not Found
{
"code": "jail_not_found",
"detail": "Jail 'sshd' not found",
"metadata": {"jail_name": "sshd"},
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
}
# 400 Bad Request - Validation Error
{
"code": "invalid_input",
"detail": "Invalid IP address format",
"metadata": {"field": "ip", "value": "999.999.999.999"},
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
}
# 409 Conflict
{
"code": "jail_already_active",
"detail": "Jail is already active: 'sshd'",
"metadata": {"jail_name": "sshd", "current_status": "active"},
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
"""
code: str = Field(..., description="Machine-readable error code for client-side branching.")
detail: str = Field(..., description="Human-readable error description.")
metadata: dict[str, str | int | float | bool | None] = Field(
default_factory=dict,
description="Optional structured context for the error.",
)
correlation_id: str | None = Field(
default=None,
description="Unique ID for correlating this error with request logs on both frontend and backend.",
)