"""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. Fields: page: Current page number (1-based). page_size: Number of items per page. total: Total number of items matching the query (across all pages). total_pages: Computed total number of pages. has_next_page: Whether there is a next page after this one. has_prev_page: Whether there is a previous page before this one. Example: ```python pagination = PaginationMetadata( page=2, page_size=50, total=150, total_pages=3, has_next_page=True, has_prev_page=True ) ``` """ page: int = Field(..., ge=1, description="Current page number (1-based).") page_size: int = Field(..., ge=1, description="Number of items per page.") total: int = Field(..., ge=0, description="Total number of items matching the query.") total_pages: int = Field(..., ge=1, description="Computed total number of pages.") 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.") 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.", )