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>
125 lines
4.2 KiB
Markdown
125 lines
4.2 KiB
Markdown
# 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.
|