Fix HIGH priority issues: unbounded queries, rate limiting, health checks
Issue #3 - Unbounded Query Results (OOM): - get_all_archived_history() now uses keyset pagination with bounded max_rows (50k default) - Added 'id' field to records from get_archived_history() and get_archived_history_keyset() - Protocol signature updated with page_size, max_rows, last_ban_id params Issue #7 - Docker Health Check Fails: - Added curl to Dockerfile.backend runtime image - HEALTHCHECK now uses 'curl -f http://localhost:8000/api/health' - compose.prod.yml: increased start_period to 40s, timeout to 10s - Frontend healthcheck proxies to backend /api/health Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
124
Docs/DOMAIN_MODELS.md
Normal file
124
Docs/DOMAIN_MODELS.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Domain Models — Reference Guide
|
||||
|
||||
This document explains the domain model pattern used in BanGUI's backend and where to find examples.
|
||||
|
||||
---
|
||||
|
||||
## What Are Domain Models?
|
||||
|
||||
Domain models (e.g., `DomainActiveBanList`, `DomainJailConfig`) are **frozen dataclasses** that represent pure business logic. They are defined in `app/models/{domain}_domain.py` and are **returned by services**.
|
||||
|
||||
Response models (e.g., `ActiveBanListResponse`, `JailConfigResponse`) are **Pydantic models** defined in `app/models/{domain}.py`. They are used **only by routers** for HTTP serialization.
|
||||
|
||||
---
|
||||
|
||||
## Why This Separation?
|
||||
|
||||
```
|
||||
Service (returns domain model)
|
||||
↓
|
||||
Router (converts domain → response via mapper)
|
||||
↓
|
||||
HTTP Response (Pydantic model)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Domain logic evolves without affecting API shape
|
||||
- Services are reusable across different frontends (GraphQL, gRPC, CLI)
|
||||
- Testing is simpler (no Pydantic overhead)
|
||||
- Changes to endpoint responses don't require service changes
|
||||
|
||||
---
|
||||
|
||||
## Existing Domain Models
|
||||
|
||||
| Domain | Domain Model(s) | Mapper Module |
|
||||
|--------|----------------|---------------|
|
||||
| **Ban** | `DomainActiveBanList`, `DomainActiveBan`, `DomainBansByCountry` | `ban_mappers.py` |
|
||||
| **Jail** | `DomainJailList`, `DomainJailDetail`, `DomainJailBannedIps`, `DomainActiveBan` | `jail_mappers.py` |
|
||||
| **Config** | `DomainJailConfig`, `DomainJailConfigList`, `DomainGlobalConfig`, `DomainServiceStatus`, `DomainBantimeEscalation`, `DomainFilterConfig`, `DomainFilterList`, `DomainRegexTest`, `DomainMapColorThresholds` | `config_mappers.py` |
|
||||
| **History** | `DomainHistoryList`, `DomainHistoryBanItem`, `DomainIpDetail`, `DomainIpTimelineEvent` | `history_mappers.py` |
|
||||
| **Server** | `DomainServerSettings`, `DomainServerSettingsResult` | `server_mappers.py` |
|
||||
| **Blocklist** | `DomainBlocklistSource`, `DomainImportLogEntry`, `DomainImportLogList`, `DomainImportSourceResult`, `DomainImportRunResult`, `DomainPreviewResult`, `DomainScheduleConfig`, `DomainScheduleInfo` | `blocklist_mappers.py` |
|
||||
|
||||
---
|
||||
|
||||
## The Pattern — Step by Step
|
||||
|
||||
### Step 1: Define Domain Model in `app/models/{domain}_domain.py`
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainJailConfig:
|
||||
"""Configuration snapshot of a single jail (domain model)."""
|
||||
|
||||
name: str
|
||||
ban_time: int
|
||||
max_retry: int
|
||||
find_time: int
|
||||
fail_regex: list[str]
|
||||
actions: list[str] # ← no default BEFORE default = FIELD ORDER ERROR
|
||||
date_pattern: str | None = None # ← all fields with defaults come AFTER
|
||||
log_encoding: LogEncoding = "UTF-8"
|
||||
```
|
||||
|
||||
**⚠️ Field Order Rule:** All fields without defaults must appear before all fields with defaults.
|
||||
|
||||
### Step 2: Add Mapper in `app/mappers/{domain}_mappers.py`
|
||||
|
||||
```python
|
||||
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
|
||||
"""Convert domain jail config to response model."""
|
||||
return JailConfig(
|
||||
name=domain.name,
|
||||
ban_time=domain.ban_time,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3: Service Returns Domain Model
|
||||
|
||||
```python
|
||||
# In app/services/jail_service.py
|
||||
from app.models.config_domain import DomainJailConfig, DomainJailConfigList
|
||||
|
||||
async def get_jail_config(socket_path: str, name: str) -> DomainJailConfig:
|
||||
...
|
||||
return DomainJailConfig(...) # ← return domain model
|
||||
```
|
||||
|
||||
### Step 4: Router Uses Mapper at Boundary
|
||||
|
||||
```python
|
||||
# In app/routers/jail_config.py
|
||||
from app.mappers import config_mappers
|
||||
|
||||
@router.get("/{name}", response_model=JailConfigResponse)
|
||||
async def get_jail_config(...) -> JailConfigResponse:
|
||||
domain_result = await config_service.get_jail_config(socket_path, name)
|
||||
return config_mappers.map_domain_jail_config_to_response(domain_result)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
`ban_service.py` + `ban_mappers.py` is the canonical example of the correct pattern. Study it first when adding a new service.
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Field Ordering Error
|
||||
|
||||
```
|
||||
TypeError: non-default argument 'actions' follows default argument
|
||||
```
|
||||
|
||||
**Fix:** Move all fields with defaults (`field: T | None = None`) after all fields without defaults.
|
||||
|
||||
### Forgetting the Mapper
|
||||
|
||||
If you refactor a service to return a domain model but forget to update the router, you'll get a type mismatch at the boundary. Always update router + service together.
|
||||
Reference in New Issue
Block a user