Enforce repository boundary: Remove DbDep from routers

This commit enforces the repository boundary by eliminating direct database connection
dependencies (DbDep) from all routers. Routers now depend on service context dependencies
that combine the database connection with the related repositories.

Changes:
- Add 5 service context dependencies in dependencies.py:
  * SessionServiceContext: db + session_repo
  * BlocklistServiceContext: db + blocklist_repo + import_log_repo + settings_repo
  * SettingsServiceContext: db + settings_repo
  * BanServiceContext: db + fail2ban_db_repo
  * HistoryServiceContext: db + fail2ban_db_repo + history_archive_repo

- Refactor all 9 routers (auth, bans, blocklist, config_misc, dashboard, geo,
  history, jails, setup) to use service contexts instead of DbDep.

- Update Backend-Development.md with clear examples of the new pattern and
  documentation of available service contexts.

Rationale:
- Enforces the repository boundary through the dependency system
- Makes database operations explicit and auditable
- Improves testability by allowing service contexts to be mocked
- Prevents accidental direct database access from routers

The deprecated DbDep remains available for backward compatibility with
services that have not yet been refactored, but routers can no longer import it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 07:35:23 +02:00
parent 813cf09bed
commit 507f153ab9
11 changed files with 318 additions and 105 deletions

View File

@@ -26,7 +26,7 @@ from fastapi import APIRouter, HTTPException, Query, status
from app.dependencies import (
AuthDep,
DbDep,
BlocklistServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
HttpSessionDep,
@@ -61,19 +61,19 @@ router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"])
summary="List all blocklist sources",
)
async def list_blocklists(
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistListResponse:
"""Return all configured blocklist source definitions.
Args:
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.blocklist.BlocklistListResponse` with all sources.
"""
sources = await blocklist_service.list_sources(db)
sources = await blocklist_service.list_sources(blocklist_ctx.db)
return BlocklistListResponse(sources=sources)
@@ -85,14 +85,14 @@ async def list_blocklists(
)
async def create_blocklist(
payload: BlocklistSourceCreate,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistSource:
"""Create a new blocklist source definition.
Args:
payload: New source data (name, url, enabled).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Returns:
@@ -103,7 +103,7 @@ async def create_blocklist(
"""
try:
return await blocklist_service.create_source(
db, payload.name, str(payload.url), enabled=payload.enabled
blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -121,7 +121,7 @@ async def create_blocklist(
)
async def run_import_now(
http_session: HttpSessionDep,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
geo_cache: GeoCacheDep,
@@ -130,8 +130,10 @@ async def run_import_now(
Args:
http_session: Shared HTTP session (injected).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
socket_path: Path to fail2ban Unix domain socket.
geo_cache: Geolocation cache instance.
Returns:
:class:`~app.models.blocklist.ImportRunResult` with per-source
@@ -139,7 +141,7 @@ async def run_import_now(
"""
return await blocklist_service.import_all(
db,
blocklist_ctx.db,
http_session,
socket_path,
geo_is_cached=geo_cache.is_cached,
@@ -154,7 +156,7 @@ async def run_import_now(
summary="Get the current import schedule",
)
async def get_schedule(
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
scheduler: SchedulerDep,
) -> ScheduleInfo:
@@ -163,14 +165,15 @@ async def get_schedule(
The ``next_run_at`` field is read from APScheduler if the job is active.
Args:
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
scheduler: APScheduler instance.
Returns:
:class:`~app.models.blocklist.ScheduleInfo` with config and run
times.
"""
return await blocklist_service.get_schedule_info_with_runtime(db, scheduler)
return await blocklist_service.get_schedule_info_with_runtime(blocklist_ctx.db, scheduler)
@router.put(
@@ -180,7 +183,7 @@ async def get_schedule(
)
async def update_schedule(
payload: ScheduleConfig,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
scheduler: SchedulerDep,
http_session: HttpSessionDep,
@@ -190,7 +193,7 @@ async def update_schedule(
Args:
payload: New :class:`~app.models.blocklist.ScheduleConfig`.
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
scheduler: Shared APScheduler instance (injected).
http_session: Shared HTTP session used by the scheduler job.
@@ -200,7 +203,7 @@ async def update_schedule(
Updated :class:`~app.models.blocklist.ScheduleInfo`.
"""
return await blocklist_service.update_schedule(
db,
blocklist_ctx.db,
scheduler,
http_session,
settings,
@@ -215,7 +218,7 @@ async def update_schedule(
summary="Get the paginated import log",
)
async def get_import_log(
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
source_id: int | None = Query(default=None, description="Filter by source id"),
page: int = Query(default=1, ge=1),
@@ -224,7 +227,7 @@ async def get_import_log(
"""Return a paginated log of all import runs.
Args:
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
source_id: Optional filter — only show logs for this source.
page: 1-based page number.
@@ -234,7 +237,7 @@ async def get_import_log(
:class:`~app.models.blocklist.ImportLogListResponse`.
"""
return await blocklist_service.list_import_logs(
db, source_id=source_id, page=page, page_size=page_size
blocklist_ctx.db, source_id=source_id, page=page, page_size=page_size
)
@@ -250,20 +253,20 @@ async def get_import_log(
)
async def get_blocklist(
source_id: int,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistSource:
"""Return a single blocklist source by id.
Args:
source_id: Primary key of the source.
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
"""
source = await blocklist_service.get_source(db, source_id)
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
return source
@@ -277,7 +280,7 @@ async def get_blocklist(
async def update_blocklist(
source_id: int,
payload: BlocklistSourceUpdate,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistSource:
"""Update one or more fields on a blocklist source.
@@ -285,7 +288,7 @@ async def update_blocklist(
Args:
source_id: Primary key of the source to update.
payload: Fields to update (all optional).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
@@ -294,7 +297,7 @@ async def update_blocklist(
"""
try:
updated = await blocklist_service.update_source(
db,
blocklist_ctx.db,
source_id,
name=payload.name,
url=str(payload.url) if payload.url is not None else None,
@@ -314,20 +317,20 @@ async def update_blocklist(
)
async def delete_blocklist(
source_id: int,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> None:
"""Delete a blocklist source by id.
Args:
source_id: Primary key of the source to remove.
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
"""
deleted = await blocklist_service.delete_source(db, source_id)
deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
@@ -340,7 +343,7 @@ async def delete_blocklist(
async def preview_blocklist(
source_id: int,
http_session: HttpSessionDep,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> PreviewResponse:
"""Download and preview a sample of a blocklist source.
@@ -350,15 +353,15 @@ async def preview_blocklist(
Args:
source_id: Primary key of the source to preview.
request: Incoming request (used to access the HTTP session).
db: Application database connection (injected).
http_session: Shared HTTP session for downloading.
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
HTTPException: 502 if the URL cannot be reached.
"""
source = await blocklist_service.get_source(db, source_id)
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")