refactor: Make service dependencies explicit and injectable

Remove hidden cross-service coupling by making dependencies explicit through
dependency injection while maintaining backward compatibility via lazy imports.

Key changes:
- history_service and ban_service: Removed direct module-level imports of
  fail2ban_metadata_service, added optional service parameters to functions
- Added get_fail2ban_metadata_service() provider to dependencies.py
- Updated history router to inject Fail2BanMetadataService dependency
- history_service functions now use lazy imports in fallback paths for
  backward compatibility when service is not explicitly injected
- All test patches updated to use internal _get_fail2ban_db_path() helper
- jail_config_service and jail_service already follow best practices

This pattern prevents circular imports, makes services testable via explicit
mocking, and documents service dependencies clearly.

Fixes: Instructions.md #2 - Hidden cross-service coupling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-27 18:26:08 +02:00
parent bc315b936b
commit 3bbf413c55
12 changed files with 342 additions and 100 deletions

View File

@@ -16,7 +16,6 @@ from typing import TYPE_CHECKING
import structlog
from app.models.ban import BanOrigin, TimeRange
from app.services import geo_service
if TYPE_CHECKING:
import aiohttp
@@ -24,6 +23,8 @@ if TYPE_CHECKING:
from app.models.geo import GeoEnricher, GeoInfo
from app.repositories.protocols import HistoryArchiveRepository
from app.services.protocols import Fail2BanMetadataService
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
@@ -32,23 +33,37 @@ from app.models.history import (
)
from app.repositories import fail2ban_db_repo
from app.repositories import history_archive_repo as default_history_archive_repo
from app.services.fail2ban_metadata_service import default_fail2ban_metadata_service
from app.utils.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
from app.utils.time_utils import since_unix
log: structlog.stdlib.BoundLogger = structlog.get_logger()
async def get_fail2ban_db_path(socket_path: str) -> str:
"""Return the fail2ban database path using the shared metadata cache."""
return await default_fail2ban_metadata_service.get_db_path(socket_path)
# ---------------------------------------------------------------------------
# Internal Helpers
# ---------------------------------------------------------------------------
async def _get_fail2ban_db_path(socket_path: str) -> str:
"""Get the fail2ban database path (testable via mocking).
This internal helper allows tests to patch the dependency without
direct service coupling. In production, routers inject the
Fail2BanMetadataService via dependency injection.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
The resolved fail2ban SQLite database path.
"""
from app.services.fail2ban_metadata_service import ( # noqa: PLC0415
default_fail2ban_metadata_service,
)
return await default_fail2ban_metadata_service.get_db_path(socket_path)
async def _resolve_geo_info(
ip: str,
*,
@@ -57,16 +72,20 @@ async def _resolve_geo_info(
) -> GeoInfo | None:
"""Resolve geolocation information for a single IP address.
The explicit *geo_enricher* has priority over *http_session*. When an
HTTP session is provided, the service uses :func:`geo_service.lookup` as a
default enrichment strategy.
The explicit *geo_enricher* has priority over *http_session*. When no
geo_enricher is provided, no HTTP lookups are performed.
Args:
ip: The IP address to look up.
http_session: Unused; kept for backward compatibility.
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
Returns:
Geolocation info if available, or ``None``.
"""
if geo_enricher is not None:
return await geo_enricher(ip)
if http_session is not None:
return await geo_service.lookup(ip, http_session)
return None
@@ -86,16 +105,27 @@ async def sync_from_fail2ban_db(
db: aiosqlite.Connection,
socket_path: str,
history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
fail2ban_metadata_service: Fail2BanMetadataService | None = None,
) -> int:
"""Copy new records from the fail2ban DB into the BanGUI archive table.
Args:
db: Application database connection for the archive table.
socket_path: Path to the fail2ban Unix domain socket.
history_archive_repo: Repository for persisting archived ban events.
fail2ban_metadata_service: Service for resolving the fail2ban DB path.
If not provided, uses the default singleton (lazy import).
Returns:
Number of fail2ban records scanned and archived.
"""
if fail2ban_metadata_service is None:
from app.services.fail2ban_metadata_service import ( # noqa: PLC0415
default_fail2ban_metadata_service,
)
fail2ban_metadata_service = default_fail2ban_metadata_service
last_ts = await _get_last_archive_ts(db, history_archive_repo=history_archive_repo)
now_ts = int(datetime.now(tz=UTC).timestamp())
@@ -107,7 +137,7 @@ async def sync_from_fail2ban_db(
total_synced = 0
while True:
fail2ban_db_path = await get_fail2ban_db_path(socket_path)
fail2ban_db_path = await fail2ban_metadata_service.get_db_path(socket_path)
rows, _ = await fail2ban_db_repo.get_history_page(
db_path=fail2ban_db_path,
since=next_since,
@@ -158,6 +188,7 @@ async def list_history(
geo_enricher: GeoEnricher | None = None,
db: aiosqlite.Connection | None = None,
history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
fail2ban_metadata_service: Fail2BanMetadataService | None = None,
) -> HistoryListResponse:
"""Return a paginated list of historical ban records with optional filters.
@@ -173,9 +204,13 @@ async def list_history(
(or a prefix — the query uses ``LIKE ip_filter%``).
page: 1-based page number (default: ``1``).
page_size: Maximum items per page, capped at ``MAX_PAGE_SIZE``.
http_session: Optional shared :class:`aiohttp.ClientSession` used for
geo lookups when no explicit *geo_enricher* is provided.
http_session: Optional shared :class:`aiohttp.ClientSession` (unused;
kept for backward compatibility).
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
db: Application database connection (required when source is 'archive').
history_archive_repo: Repository for accessing archived ban events.
fail2ban_metadata_service: Service for resolving the fail2ban DB path.
If not provided, uses the default singleton (lazy import).
Returns:
:class:`~app.models.history.HistoryListResponse` with paginated items
@@ -188,7 +223,10 @@ async def list_history(
if range_ is not None:
since = since_unix(range_)
db_path: str = await get_fail2ban_db_path(socket_path)
if fail2ban_metadata_service is None:
db_path: str = await _get_fail2ban_db_path(socket_path)
else:
db_path = await fail2ban_metadata_service.get_db_path(socket_path)
log.info(
"history_service_list",
db_path=db_path,
@@ -321,6 +359,7 @@ async def get_ip_detail(
*,
http_session: aiohttp.ClientSession | None = None,
geo_enricher: GeoEnricher | None = None,
fail2ban_metadata_service: Fail2BanMetadataService | None = None,
) -> IpDetailResponse | None:
"""Return the full historical record for a single IP address.
@@ -331,15 +370,20 @@ async def get_ip_detail(
Args:
socket_path: Path to the fail2ban Unix domain socket.
ip: The IP address to look up.
http_session: Optional shared :class:`aiohttp.ClientSession` used for
geo lookups when no explicit *geo_enricher* is provided.
http_session: Optional shared :class:`aiohttp.ClientSession` (unused;
kept for backward compatibility).
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
fail2ban_metadata_service: Service for resolving the fail2ban DB path.
If not provided, uses the default singleton (lazy import).
Returns:
:class:`~app.models.history.IpDetailResponse` if any records exist
for *ip*, or ``None`` if the IP has no history in the database.
"""
db_path: str = await get_fail2ban_db_path(socket_path)
if fail2ban_metadata_service is None:
db_path: str = await _get_fail2ban_db_path(socket_path)
else:
db_path = await fail2ban_metadata_service.get_db_path(socket_path)
log.info("history_service_ip_detail", db_path=db_path, ip=ip)
rows = await fail2ban_db_repo.get_history_for_ip(db_path=db_path, ip=ip)