No canonical snake_case/camelCase serialization policy

This commit is contained in:
2026-04-28 21:27:26 +02:00
parent b27765928a
commit ad21590f60
14 changed files with 186 additions and 475 deletions

View File

@@ -6,9 +6,10 @@ Request, response, and domain models used by the ban router and service.
import math
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field
from app.models.response import BanGuiBaseModel, CollectionResponse, PaginatedListResponse
from app.models.response import CollectionResponse, PaginatedListResponse
# ---------------------------------------------------------------------------
# Time-range selector
@@ -25,21 +26,15 @@ TIME_RANGE_SECONDS: dict[str, int] = {
"365d": 365 * 24 * 3600,
}
class BanRequest(BaseModel):
class BanRequest(BanGuiBaseModel):
"""Payload for ``POST /api/bans`` (ban an IP)."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address to ban.")
jail: str = Field(..., description="Jail in which to apply the ban.")
class UnbanRequest(BaseModel):
class UnbanRequest(BanGuiBaseModel):
"""Payload for ``DELETE /api/bans`` (unban an IP)."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address to unban.")
jail: str | None = Field(
default=None,
@@ -50,14 +45,12 @@ class UnbanRequest(BaseModel):
description="When ``true`` the IP is unbanned from every jail.",
)
#: Discriminator literal for the origin of a ban.
BanOrigin = Literal["blocklist", "selfblock"]
#: Jail name used by the blocklist import service.
BLOCKLIST_JAIL: str = "blocklist-import"
def _derive_origin(jail: str) -> BanOrigin:
"""Derive the ban origin from the jail name.
@@ -70,12 +63,9 @@ def _derive_origin(jail: str) -> BanOrigin:
"""
return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock"
class Ban(BaseModel):
class Ban(BanGuiBaseModel):
"""Domain model representing a single active or historical ban record."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -93,15 +83,11 @@ class Ban(BaseModel):
description="Whether this ban came from a blocklist import or fail2ban itself.",
)
class BanResponse(BaseModel):
class BanResponse(BanGuiBaseModel):
"""Response containing a single ban record."""
model_config = ConfigDict(strict=True)
ban: Ban
class BanListResponse(PaginatedListResponse[Ban]):
"""Paginated list of ban records.
@@ -114,12 +100,9 @@ class BanListResponse(PaginatedListResponse[Ban]):
pass
class ActiveBan(BaseModel):
class ActiveBan(BanGuiBaseModel):
"""A currently active ban entry returned by ``GET /api/bans/active``."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail holding the ban.")
banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.")
@@ -130,7 +113,6 @@ class ActiveBan(BaseModel):
ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.")
country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
class ActiveBanListResponse(CollectionResponse[ActiveBan]):
"""List of all currently active bans across all jails.
@@ -143,29 +125,22 @@ class ActiveBanListResponse(CollectionResponse[ActiveBan]):
pass
class UnbanAllResponse(BaseModel):
class UnbanAllResponse(BanGuiBaseModel):
"""Response for ``DELETE /api/bans/all``."""
model_config = ConfigDict(strict=True)
message: str = Field(..., description="Human-readable summary of the operation.")
count: int = Field(..., ge=0, description="Number of IPs that were unbanned.")
# ---------------------------------------------------------------------------
# Dashboard ban-list view models
# ---------------------------------------------------------------------------
class DashboardBanItem(BaseModel):
class DashboardBanItem(BanGuiBaseModel):
"""A single row in the dashboard ban-list table.
Populated from the fail2ban database and enriched with geo data.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -195,7 +170,6 @@ class DashboardBanItem(BaseModel):
description="Whether this ban came from a blocklist import or fail2ban itself.",
)
class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
"""Paginated dashboard ban-list response.
@@ -205,8 +179,7 @@ class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
pass
class BansByCountryResponse(BaseModel):
class BansByCountryResponse(BanGuiBaseModel):
"""Response for the bans-by-country aggregation endpoint.
Contains a per-country ban count, a human-readable country name map, and
@@ -215,8 +188,6 @@ class BansByCountryResponse(BaseModel):
single request.
"""
model_config = ConfigDict(strict=True)
countries: dict[str, int] = Field(
default_factory=dict,
description="ISO 3166-1 alpha-2 country code → ban count.",
@@ -231,7 +202,6 @@ class BansByCountryResponse(BaseModel):
)
total: int = Field(..., ge=0, description="Total ban count in the window.")
# ---------------------------------------------------------------------------
# Trend endpoint models
# ---------------------------------------------------------------------------
@@ -252,7 +222,6 @@ BUCKET_SIZE_LABEL: dict[str, str] = {
"365d": "7d",
}
def bucket_count(range_: TimeRange) -> int:
"""Return the number of buckets needed to cover *range_* completely.
@@ -266,21 +235,15 @@ def bucket_count(range_: TimeRange) -> int:
"""
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
class BanTrendBucket(BaseModel):
class BanTrendBucket(BanGuiBaseModel):
"""A single time bucket in the ban trend series."""
model_config = ConfigDict(strict=True)
timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.")
count: int = Field(..., ge=0, description="Number of bans that started in this bucket.")
class BanTrendResponse(BaseModel):
class BanTrendResponse(BanGuiBaseModel):
"""Response for the ``GET /api/dashboard/bans/trend`` endpoint."""
model_config = ConfigDict(strict=True)
buckets: list[BanTrendBucket] = Field(
default_factory=list,
description="Time-ordered list of ban-count buckets covering the full window.",
@@ -290,38 +253,29 @@ class BanTrendResponse(BaseModel):
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
)
# ---------------------------------------------------------------------------
# By-jail endpoint models
# ---------------------------------------------------------------------------
class JailBanCount(BaseModel):
class JailBanCount(BanGuiBaseModel):
"""A single jail entry in the bans-by-jail aggregation."""
model_config = ConfigDict(strict=True)
jail: str = Field(..., description="Jail name.")
count: int = Field(..., ge=0, description="Number of bans recorded in this jail.")
class BansByJailResponse(BaseModel):
class BansByJailResponse(BanGuiBaseModel):
"""Response for the ``GET /api/dashboard/bans/by-jail`` endpoint."""
model_config = ConfigDict(strict=True)
jails: list[JailBanCount] = Field(
default_factory=list,
description="Jails ordered by ban count descending.",
)
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
# ---------------------------------------------------------------------------
# Jail-specific paginated bans
# ---------------------------------------------------------------------------
class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]):
"""Paginated response for ``GET /api/jails/{name}/banned``.