"""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( default="offset", 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. url: URL involved in a blocklist 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 url: str 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 field_errors: int first_field: 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'. external_logging: External logging handler status — 'ok', 'error', or 'disabled'. 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", "external_logging": "disabled", "components": [] } # Unhealthy (HTTP 503) { "status": "unavailable", "fail2ban": "offline", "database": "ok", "scheduler": "running", "cache": "initialised", "external_logging": "ok", "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.", ) external_logging: Literal["ok", "error", "disabled"] = Field( ..., description=( "External logging handler status: 'ok' when operational, 'error' when " "initialization failed, 'disabled' when external logging is not configured." ), ) 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.") class ReadyCheck(BanGuiBaseModel): """Result of a single readiness subsystem check. Fields: name: Subsystem name (e.g., "database", "fail2ban", "config_dir"). healthy: True when the subsystem is reachable/operational. message: Optional error message describing the failure. """ name: str = Field(..., description="Subsystem name.") healthy: bool = Field(..., description="True when the subsystem is operational.") message: str | None = Field( default=None, description="Error detail when the check fails.", ) class ReadyResponse(BanGuiBaseModel): """Structured readiness check response for the ``/health/ready`` endpoint. Fields: status: "ok" when all checks pass, "error" when at least one failed. checks: Per-subsystem result list. failed_count: Number of checks that returned healthy=False. Example: ```python # All healthy (HTTP 200) {"status": "ok", "checks": [...], "failed_count": 0} # Some failed (HTTP 503) {"status": "error", "checks": [...], "failed_count": 2} ``` """ status: Literal["ok", "error"] = Field( ..., description="'ok' when all checks pass, 'error' when at least one fails.", ) checks: list[ReadyCheck] = Field( default_factory=list, description="Per-subsystem check results.", ) failed_count: int = Field( ..., ge=0, description="Number of checks that returned healthy=False.", )