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

@@ -8,6 +8,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from app.models.response import CollectionResponse, PaginatedListResponse
# ---------------------------------------------------------------------------
# Time-range selector
# ---------------------------------------------------------------------------
@@ -100,13 +102,17 @@ class BanResponse(BaseModel):
ban: Ban
class BanListResponse(BaseModel):
"""Paginated list of ban records."""
class BanListResponse(PaginatedListResponse[Ban]):
"""Paginated list of ban records.
model_config = ConfigDict(strict=True)
Request: `GET /api/bans` with optional pagination and filter parameters.
Response: Paginated collection of ban records with total count.
bans: list[Ban] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total number of matching records.")
Note: Unlike most list endpoints, this endpoint uses `page` and `page_size`
for pagination. When using this response, ensure the router provides these fields.
"""
pass
class ActiveBan(BaseModel):
@@ -125,13 +131,17 @@ class ActiveBan(BaseModel):
country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
class ActiveBanListResponse(BaseModel):
"""List of all currently active bans across all jails."""
class ActiveBanListResponse(CollectionResponse[ActiveBan]):
"""List of all currently active bans across all jails.
model_config = ConfigDict(strict=True)
Request: `GET /api/bans/active` with optional filter parameters.
Response: Non-paginated collection of currently active bans with total count.
bans: list[ActiveBan] = Field(default_factory=list)
total: int = Field(..., ge=0)
Note: This endpoint does not support pagination. All matching bans are returned.
For paginated results, use individual jail endpoints or the dashboard ban-list view.
"""
pass
class UnbanAllResponse(BaseModel):
@@ -186,15 +196,14 @@ class DashboardBanItem(BaseModel):
)
class DashboardBanListResponse(BaseModel):
"""Paginated dashboard ban-list response."""
class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
"""Paginated dashboard ban-list response.
model_config = ConfigDict(strict=True)
Request: `GET /api/dashboard/bans` with time range, page, and filter parameters.
Response: Paginated collection of dashboard ban items with geo-enrichment.
"""
items: list[DashboardBanItem] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total bans in the selected time window.")
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
pass
class BansByCountryResponse(BaseModel):
@@ -313,23 +322,14 @@ class BansByJailResponse(BaseModel):
# ---------------------------------------------------------------------------
class JailBannedIpsResponse(BaseModel):
class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]):
"""Paginated response for ``GET /api/jails/{name}/banned``.
Contains only the current page of active ban entries for a single jail,
geo-enriched exclusively for the page slice to avoid rate-limit issues.
Request: `GET /api/jails/{name}/banned` with page and page_size parameters.
Response: Paginated collection of active bans for the specified jail.
"""
model_config = ConfigDict(strict=True)
items: list[ActiveBan] = Field(
default_factory=list,
description="Active ban entries for the current page.",
)
total: int = Field(
...,
ge=0,
description="Total matching entries (after applying the search filter).",
)
page: int = Field(..., ge=1, description="Current page number (1-based).")
page_size: int = Field(..., ge=1, description="Number of items per page.")
pass