Standardize API response envelope shapes across all endpoints

This commit standardizes how API responses are wrapped, solving issue #24.

Problem:
- Inconsistent response envelopes (jails vs items vs bans vs no wrapper)
- Frontend required multiple field name variants
- Integration bugs from branching logic
- No clear pattern for different response types

Solution:
- Created response.py with base classes: PaginatedListResponse,
  CollectionResponse, CommandResponse
- Standardized all list/collection responses to use 'items' field
- Domain-specific field names for detail and aggregation responses
- Updated all backends routers and mappers
- Updated frontend types and hooks to match

Changes:
Backend:
- backend/app/models/response.py (new): Base response models
- backend/app/models/ban.py: Updated responses to inherit from bases
- backend/app/models/jail.py: Updated JailListResponse, JailCommandResponse
- backend/app/models/config.py: Updated collection responses
- backend/app/services/jail_service.py: Updated return statements
- backend/app/mappers/ban_mappers.py: Updated 'bans' to 'items'
- backend/tests/test_mappers/test_ban_mappers.py: Updated tests

Frontend:
- frontend/src/types/jail.ts: Updated response interfaces
- frontend/src/types/config.ts: Updated response interfaces
- frontend/src/hooks/useActiveBans.ts: Updated selector
- frontend/src/hooks/useJailList.ts: Updated selector
- frontend/src/hooks/useJailConfigs.ts: Updated selector
- frontend/src/hooks/useConfigActiveStatus.ts: Updated field access
- frontend/src/hooks/useJailAdmin.ts: Updated field access

Documentation:
- Docs/Backend-Development.md: Added § 4.1 API Response Envelope Policy

The policy defines:
1. Paginated lists use PaginatedListResponse (items, total, page, page_size)
2. Non-paginated collections use CollectionResponse (items, total)
3. Detail responses use entity-specific field names (jail, status, settings)
4. Command responses use CommandResponse (message, success, optional target)
5. Aggregations use domain-specific fields (jails, countries, buckets, bans)

All responses now follow one of these patterns, reducing frontend complexity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 10:12:55 +02:00
parent 7ba1cf7ca2
commit 1c673d600c
16 changed files with 415 additions and 86 deletions

View File

@@ -310,6 +310,158 @@ async def login(
---
## 4.1 API Response Envelope Policy
All API responses must follow a **consistent wrapper pattern**. This standardization reduces frontend branching logic, prevents integration bugs, and makes the API easier to document and maintain.
### Response Patterns
#### Pattern 1: Paginated Lists
Use `PaginatedListResponse[T]` for endpoints that return paginated collections:
```python
from app.models.response import PaginatedListResponse
class JailListResponse(PaginatedListResponse[JailSummary]):
"""Response for ``GET /api/jails``."""
pass
# Returns:
{
"items": [...], # T[]
"total": 100, # int: total items across all pages
"page": 2, # int: current page (1-based)
"page_size": 20 # int: items per page
}
```
**When to use:** Endpoints that support pagination parameters (`page`, `page_size`, `limit`, `offset`).
#### Pattern 2: Non-Paginated Collections
Use `CollectionResponse[T]` for endpoints that return a complete collection without pagination:
```python
from app.models.response import CollectionResponse
class JailConfigListResponse(CollectionResponse[JailConfig]):
"""Response for ``GET /api/config/jails``."""
pass
# Returns:
{
"items": [...], # T[]
"total": 42 # int: total items
}
```
**When to use:** Endpoints that return a complete collection (not paginated). The frontend can render all items without worrying about paging.
#### Pattern 3: Single-Item Detail Responses
Use **domain-specific field names** (not generic wrappers) for detail endpoints:
```python
class JailDetailResponse(BaseModel):
"""Response for ``GET /api/jails/{name}``."""
jail: Jail
ignore_list: list[str]
ignore_self: bool
# Returns:
{
"jail": { ... }, # Jail object
"ignore_list": [...], # Additional context
"ignore_self": true
}
```
**When to use:** Endpoints that fetch a single entity. Use the entity name as the field (jail, status, settings, etc.).
**Field naming:**
- Primary entity uses its own name: `jail`, `status`, `settings`, etc.
- Related or supplementary data uses descriptive names: `ignore_list`, `warnings`, `metadata`, etc.
#### Pattern 4: Command/Action Responses
Use `CommandResponse` for endpoints that execute commands:
```python
from app.models.response import CommandResponse
class JailCommandResponse(CommandResponse):
"""Generic response for jail control commands."""
jail: str # Target identifier (optional)
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd" # Optional: target identifier
}
```
**When to use:** POST/PUT/DELETE endpoints that perform operations (start jail, ban IP, update config, etc.).
**Fields:**
- `message: str` — Human-readable result or error description.
- `success: bool` — Operation succeeded (default: true). Use false for non-exception error handlers.
- Optional domain-specific fields (jail, ip, etc.) to identify the affected resource.
#### Pattern 5: Aggregation Responses
Use domain-specific field names for aggregated data:
```python
class BansByJailResponse(BaseModel):
"""Response for ``GET /api/dashboard/bans/by-jail``."""
jails: list[JailBanCount] # Aggregated per-jail data
total: int # Total count across all jails
class BansByCountryResponse(BaseModel):
"""Response for ``GET /api/dashboard/bans/by-country``."""
countries: dict[str, int] # Country code → count
country_names: dict[str, str] # Country code → name
bans: list[DashboardBanItem] # Full list for rendering companion table
total: int # Total ban count
# Returns:
{
"jails": [ { "jail": "sshd", "count": 42 }, ... ],
"total": 500
}
```
**When to use:** Endpoints that return computed/aggregated data. Use field names that reflect the data (jails, countries, buckets, etc.).
### Summary Table
| Pattern | Used for | Field Names | Example |
|---------|----------|---|---|
| **PaginatedListResponse** | Paginated collections | `items`, `total`, `page`, `page_size` | `GET /api/dashboard/bans` |
| **CollectionResponse** | Complete collections | `items`, `total` | `GET /api/config/jails` |
| **Detail Response** | Single entity + metadata | Entity name + descriptors | `GET /api/jails/{name}` |
| **CommandResponse** | Action results | `message`, `success` + optional identifiers | `POST /api/jails/{name}/start` |
| **Aggregation Response** | Computed data | Domain-specific names | `GET /api/dashboard/bans/by-jail` |
### Rules
1. **Always wrap lists in `items` field** — Consistency aids frontend parsing.
- ✅ `{ "items": [...], "total": 100 }`
- ❌ `{ "jails": [...], "total": 100 }` (for list endpoints; OK for aggregations)
2. **Aggregation responses are exceptions** — They use domain-specific field names because the data represents computed results, not a simple list.
- ✅ `{ "countries": {...}, "jails": [...], "total": 100 }`
3. **Every response with >1 item must include `total`** — Enables frontend to understand scale.
4. **Paginated responses must include `page` and `page_size`** — Enables the frontend to render pagination controls.
5. **No ad-hoc wrapper objects** — Don't invent new response shapes. Use the patterns above.
---
## 5. Pydantic Models
- Every model inherits from `pydantic.BaseModel`.