- Extract pagination logic to separate util module - Update response models to use new pagination util - Fix pagination calculation edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
483 lines
16 KiB
Python
483 lines
16 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, Literal, TypeVar
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from typing_extensions import TypedDict
|
|
|
|
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.
|
|
pagination_mode: Pagination mode used by the endpoint. 'offset' uses page/page_size;
|
|
'cursor' uses cursor tokens for navigation.
|
|
|
|
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,
|
|
pagination_mode="offset",
|
|
)
|
|
```
|
|
|
|
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=",
|
|
pagination_mode="cursor",
|
|
)
|
|
```
|
|
"""
|
|
|
|
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).",
|
|
)
|
|
pagination_mode: Literal["offset", "cursor"] = Field(
|
|
...,
|
|
description="Pagination mode used by the endpoint. 'offset' uses page/page_size; 'cursor' uses cursor tokens.",
|
|
)
|
|
|
|
|
|
|
|
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: "ErrorMetadata" = 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.",
|
|
)
|
|
|
|
|
|
# ErrorMetadata must be defined after ErrorResponse due to Pydantic forward-ref resolution
|
|
# but before use at type-check time. This ordering is intentional.
|
|
|
|
|
|
class ErrorMetadata(TypedDict, total=False):
|
|
"""Typed metadata fields for error responses.
|
|
|
|
Allows type-safe access to known metadata keys in exception handlers.
|
|
Keys are optional — exceptions return only relevant fields.
|
|
|
|
Fields:
|
|
jail_name: Name of the jail involved in the error.
|
|
filename: Config filename involved in the error.
|
|
filter_name: Name of the filter involved in the error.
|
|
action_name: Name of the action involved in the error.
|
|
source_id: ID of a blocklist source involved in the error.
|
|
ip: IP address involved in the error.
|
|
pattern: Regex pattern that caused an error.
|
|
error: Regex compilation error message.
|
|
pattern_length: Actual length of an oversized pattern.
|
|
max_length: Maximum allowed length for a pattern.
|
|
timeout_seconds: Timeout value for regex compilation.
|
|
retry_after_seconds: Seconds to wait before retrying (rate limit errors).
|
|
socket_path: fail2ban socket path for connection errors.
|
|
current_status: Current jail status for conflict errors.
|
|
actual_length: Actual pattern length (alias for pattern_length).
|
|
message: Generic error message string.
|
|
"""
|
|
|
|
jail_name: str
|
|
filename: str
|
|
filter_name: str
|
|
action_name: str
|
|
source_id: int
|
|
ip: str
|
|
pattern: str
|
|
error: str
|
|
pattern_length: int
|
|
max_length: int
|
|
timeout_seconds: int
|
|
retry_after_seconds: float
|
|
socket_path: str
|
|
current_status: str
|
|
actual_length: int
|
|
message: str
|
|
|
|
|
|
class ComponentHealth(BanGuiBaseModel):
|
|
"""Health status of a single application component.
|
|
|
|
Fields:
|
|
name: Human-readable component name.
|
|
healthy: True when the component is operational.
|
|
message: Optional detail message (e.g., error description).
|
|
"""
|
|
|
|
name: str = Field(..., description="Component name.")
|
|
healthy: bool = Field(..., description="True when the component is operational.")
|
|
message: str | None = Field(
|
|
default=None,
|
|
description="Optional detail message, e.g. error description.",
|
|
)
|
|
|
|
|
|
class HealthResponse(BanGuiBaseModel):
|
|
"""Standardized response for the health check endpoint.
|
|
|
|
Fields:
|
|
status: Application health status — 'ok' when all components are healthy,
|
|
'degraded' when some components are unhealthy but the service can still
|
|
handle requests, 'unavailable' when fail2ban is offline.
|
|
fail2ban: fail2ban daemon status — 'online' or 'offline'.
|
|
database: Database connectivity — 'ok' or 'error'.
|
|
scheduler: Background scheduler status — 'running', 'stopped', or 'unknown'.
|
|
cache: Cache initialization status — 'initialised' or 'uninitialised'.
|
|
components: Per-component health detail list (empty when all healthy).
|
|
|
|
Example:
|
|
```python
|
|
# Healthy (HTTP 200)
|
|
{
|
|
"status": "ok",
|
|
"fail2ban": "online",
|
|
"database": "ok",
|
|
"scheduler": "running",
|
|
"cache": "initialised",
|
|
"components": []
|
|
}
|
|
|
|
# Unhealthy (HTTP 503)
|
|
{
|
|
"status": "unavailable",
|
|
"fail2ban": "offline",
|
|
"database": "ok",
|
|
"scheduler": "running",
|
|
"cache": "initialised",
|
|
"components": [{"name": "fail2ban", "healthy": false, "message": "Socket not reachable"}]
|
|
}
|
|
```
|
|
"""
|
|
|
|
status: Literal["ok", "degraded", "unavailable"] = Field(
|
|
...,
|
|
description=(
|
|
"Application health status: 'ok' when healthy, 'degraded' when some "
|
|
"components are unhealthy, 'unavailable' when fail2ban is offline."
|
|
),
|
|
)
|
|
fail2ban: Literal["online", "offline"] = Field(
|
|
...,
|
|
description="fail2ban daemon status: 'online' when reachable, 'offline' otherwise.",
|
|
)
|
|
database: Literal["ok", "error"] = Field(
|
|
...,
|
|
description="Database connectivity: 'ok' when accessible, 'error' when not.",
|
|
)
|
|
scheduler: Literal["running", "stopped", "unknown"] = Field(
|
|
...,
|
|
description="Background scheduler status: 'running', 'stopped', or 'unknown'.",
|
|
)
|
|
cache: Literal["initialised", "uninitialised"] = Field(
|
|
...,
|
|
description="Cache initialization status: 'initialised' when ready, 'uninitialised' when not.",
|
|
)
|
|
components: list[ComponentHealth] = Field(
|
|
default_factory=list,
|
|
description="Per-component health detail list. Empty when status is 'ok'.",
|
|
)
|
|
|
|
|
|
class FlushLogsResponse(BanGuiBaseModel):
|
|
"""Standardized response for the flush-logs command endpoint.
|
|
|
|
Fields:
|
|
message: Human-readable result message from fail2ban.
|
|
|
|
Example:
|
|
```python
|
|
{"message": "Success: fail2ban log files were flushed."}
|
|
```
|
|
"""
|
|
|
|
message: str = Field(..., description="Human-readable result message from fail2ban.")
|