Break up long function into focused helper. Load data logic separate from aggregation.
475 lines
16 KiB
Python
475 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.
|
|
|
|
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: "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.")
|