Task 8: Standardize modeling style (TypedDict vs Pydantic)

Convert inconsistent modeling style to standardized Pydantic models for all
external-facing data structures while maintaining TypedDict compatibility where
appropriate for internal layer-private structures.

Changes:
- Converted IpLookupResult TypedDict to use IpLookupResponse Pydantic model
  in jail_service.lookup_ip() for consistency with routers
- Added GeoCacheEntry Pydantic model for geo cache repository rows
- Converted GeoCacheRow TypedDict to use GeoCacheEntry alias
- Converted ImportLogRow TypedDict to use ImportLogEntry alias
- Updated routers and services to work with Pydantic models
- Updated all tests to use Pydantic model field access (attributes)
  instead of dict subscripting

Documentation:
- Added 'Model Type Usage by Layer' section to Backend-Development.md
- Defines when TypedDict is allowed (internal structures) vs Pydantic
  (external-facing, cross-boundary data)
- Provides clear guidance on modeling conventions per layer

Benefits:
- Consistent validation and serialization behavior
- Better IDE support and type checking
- Clearer separation of concerns by layer
- Reduced maintenance cost from mixed validation approaches

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 07:53:30 +02:00
parent 3888c5eb3f
commit 52a4d04d92
10 changed files with 127 additions and 85 deletions

View File

@@ -401,6 +401,69 @@ async def delete_log_path(
- **Never use string prefix matching** for path validation (e.g., `path.startswith("/var/log")`). The helper uses `Path.relative_to()` to prevent bypasses like `/var/log_evil/file.log`.
- Symlinks are resolved before validating to prevent symlink-based escapes.
### Model Type Usage by Layer
**Pydantic models** are mandatory for all **external-facing** data structures — anything that crosses layer boundaries or is serialized to HTTP responses. **TypedDict** may be used **only** for internal, layer-private data structures where they provide precise typing without runtime overhead.
**Rules:**
1. **Routers (HTTP boundary):** All request and response types **must be Pydantic models**. FastAPI uses these for validation, serialization, and OpenAPI documentation.
- Use Pydantic request models for request bodies and query parameters.
- Use Pydantic response models in the `response_model` parameter.
```python
# Good — Pydantic models for router layer
class JailStatsRequest(BaseModel):
jail_name: str
class JailStatsResponse(BaseModel):
jail_name: str
active_bans: int
@router.post("/stats", response_model=JailStatsResponse)
async def get_stats(req: JailStatsRequest) -> JailStatsResponse:
...
```
2. **Services (business logic):** Return types should be **Pydantic models** if the result is:
- Returned to a router (likely — they become API responses).
- Used across multiple services (shared interfaces).
- Exposed to external consumers (even indirectly).
If a service returns a purely internal intermediate result used by a single caller, TypedDict is acceptable but should be rare.
```python
# Good — service returns Pydantic (may be used by multiple routers)
async def get_jail_details(name: str) -> JailDetailResponse:
...
# Acceptable — purely internal utility result
def _parse_fail2ban_response(raw: str) -> ParsedResponse:
"""Internal helper—used only by this service."""
...
```
3. **Repositories (data access):** Return types may use **TypedDict** because they represent **raw database rows** that:
- Are layer-private (only called by their own service).
- Do not cross HTTP boundaries directly.
- Benefit from lightweight typing without runtime validation.
```python
# Good — TypedDict for raw repository rows
class GeoRow(TypedDict):
ip: str
country_code: str | None
async def load_all(db: aiosqlite.Connection) -> list[GeoRow]:
...
```
If a repository result becomes part of a service's public interface (returned to routers or other services), convert it to a Pydantic model.
4. **Utilities and helpers:** Internal helper results may use TypedDict if they are not part of a public module interface.
**Migration path:** Existing internal TypedDicts (e.g., `GeoCacheRow`, `ImportLogRow`) may remain as TypedDicts so long as they stay within their layer. If a type needs to cross layer boundaries (repo → service → router), convert it to a Pydantic model incrementally as you refactor that data flow.
---
## 6. Async Rules