diff --git a/.env.dist b/.env.dist
index 1abc2eaf..ed527761 100644
--- a/.env.dist
+++ b/.env.dist
@@ -45,7 +45,6 @@ UNKNOWN_PLAYER_MAX_RETRY_AFTER=21600
# Cache configuration
CACHE_TTL_HEADER=X-Cache-TTL
-PLAYER_CACHE_TIMEOUT=86400
HEROES_PATH_CACHE_TIMEOUT=86400
HERO_PATH_CACHE_TIMEOUT=86400
CSV_CACHE_TIMEOUT=86400
@@ -54,6 +53,14 @@ SEARCH_ACCOUNT_PATH_CACHE_TIMEOUT=600
HERO_STATS_CACHE_TIMEOUT=3600
PLAYER_PROFILE_MAX_AGE=259200
+# SWR staleness thresholds
+HEROES_STALENESS_THRESHOLD=86400 # 24 hours
+MAPS_STALENESS_THRESHOLD=86400
+GAMEMODES_STALENESS_THRESHOLD=86400
+ROLES_STALENESS_THRESHOLD=86400
+PLAYER_STALENESS_THRESHOLD=3600 # 1 hour
+STALE_CACHE_TIMEOUT=60
+
# Critical error Discord webhook
DISCORD_WEBHOOK_ENABLED=false
DISCORD_WEBHOOK_URL=""
diff --git a/app/adapters/blizzard/parsers/hero.py b/app/adapters/blizzard/parsers/hero.py
index 3b0fa82d..689d2bd3 100644
--- a/app/adapters/blizzard/parsers/hero.py
+++ b/app/adapters/blizzard/parsers/hero.py
@@ -16,7 +16,7 @@
if TYPE_CHECKING:
from selectolax.lexbor import LexborNode
- from app.adapters.blizzard.client import BlizzardClient
+ from app.domain.ports import BlizzardClientPort
from app.enums import Locale
from app.exceptions import ParserBlizzardError, ParserParsingError
@@ -26,7 +26,7 @@
async def fetch_hero_html(
- client: BlizzardClient,
+ client: BlizzardClientPort,
hero_key: str,
locale: Locale = Locale.ENGLISH_US,
) -> str:
@@ -288,7 +288,7 @@ def _parse_hero_stadium_powers(stadium_wrapper: LexborNode) -> list[dict]:
async def parse_hero(
- client: BlizzardClient,
+ client: BlizzardClientPort,
hero_key: str,
locale: Locale = Locale.ENGLISH_US,
) -> dict:
diff --git a/app/adapters/blizzard/parsers/hero_stats_summary.py b/app/adapters/blizzard/parsers/hero_stats_summary.py
index fe4fd5c3..7011ffad 100644
--- a/app/adapters/blizzard/parsers/hero_stats_summary.py
+++ b/app/adapters/blizzard/parsers/hero_stats_summary.py
@@ -10,7 +10,7 @@
from app.players.enums import PlayerGamemode, PlayerPlatform, PlayerRegion
if TYPE_CHECKING:
- from app.adapters.blizzard.client import BlizzardClient
+ from app.domain.ports import BlizzardClientPort
# Mappings for query parameters
PLATFORM_MAPPING: dict[PlayerPlatform, str] = {
@@ -25,7 +25,7 @@
async def fetch_hero_stats_json(
- client: BlizzardClient,
+ client: BlizzardClientPort,
platform: PlayerPlatform,
gamemode: PlayerGamemode,
region: PlayerRegion,
@@ -125,7 +125,7 @@ def _normalize_rate(rate: float) -> float:
async def parse_hero_stats_summary(
- client: BlizzardClient,
+ client: BlizzardClientPort,
platform: PlayerPlatform,
gamemode: PlayerGamemode,
region: PlayerRegion,
diff --git a/app/adapters/blizzard/parsers/heroes.py b/app/adapters/blizzard/parsers/heroes.py
index 23c122dc..283f2ce6 100644
--- a/app/adapters/blizzard/parsers/heroes.py
+++ b/app/adapters/blizzard/parsers/heroes.py
@@ -14,11 +14,11 @@
from app.heroes.enums import HeroGamemode
if TYPE_CHECKING:
- from app.adapters.blizzard.client import BlizzardClient
+ from app.domain.ports import BlizzardClientPort
async def fetch_heroes_html(
- client: BlizzardClient,
+ client: BlizzardClientPort,
locale: Locale = Locale.ENGLISH_US,
) -> str:
"""
@@ -95,7 +95,7 @@ def filter_heroes(
async def parse_heroes(
- client: BlizzardClient,
+ client: BlizzardClientPort,
locale: Locale = Locale.ENGLISH_US,
role: str | None = None,
gamemode: HeroGamemode | None = None,
diff --git a/app/adapters/blizzard/parsers/heroes_hitpoints.py b/app/adapters/blizzard/parsers/heroes_hitpoints.py
new file mode 100644
index 00000000..ad721046
--- /dev/null
+++ b/app/adapters/blizzard/parsers/heroes_hitpoints.py
@@ -0,0 +1,25 @@
+"""Stateless parser functions for heroes hitpoints data (HP, armor, shields) from CSV"""
+
+from app.adapters.csv import CSVReader
+
+HITPOINTS_KEYS = {"health", "armor", "shields"}
+
+
+def parse_heroes_hitpoints() -> dict[str, dict]:
+ """Parse heroes hitpoints (health/armor/shields) from the heroes CSV file.
+
+ Returns:
+ Dict mapping hero key to hitpoints data.
+ Example: {"ana": {"hitpoints": {"health": 200, "armor": 0, "shields": 0, "total": 200}}}
+ """
+ csv_reader = CSVReader()
+ csv_data = csv_reader.read_csv_file("heroes")
+
+ return {row["key"]: {"hitpoints": _get_hitpoints(row)} for row in csv_data}
+
+
+def _get_hitpoints(row: dict) -> dict:
+ """Extract hitpoints data from a hero CSV row."""
+ hitpoints = {hp_key: int(row[hp_key]) for hp_key in HITPOINTS_KEYS}
+ hitpoints["total"] = sum(hitpoints.values())
+ return hitpoints
diff --git a/app/adapters/blizzard/parsers/heroes_stats.py b/app/adapters/blizzard/parsers/heroes_stats.py
deleted file mode 100644
index 6a950468..00000000
--- a/app/adapters/blizzard/parsers/heroes_stats.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""Stateless parser functions for heroes stats data (HP, armor, shields)"""
-
-from app.adapters.csv import CSVReader
-
-HITPOINTS_KEYS = {"health", "armor", "shields"}
-
-
-def parse_heroes_stats_csv() -> dict[str, dict]:
- """
- Parse heroes stats (hitpoints) from CSV file
-
- Returns:
- Dict mapping hero key to stats dict with hitpoints data
- Example: {"ana": {"hitpoints": {"health": 200, "armor": 0, "shields": 0, "total": 200}}}
- """
- csv_reader = CSVReader()
- csv_data = csv_reader.read_csv_file("heroes")
-
- return {
- hero_stats["key"]: {"hitpoints": _get_hitpoints(hero_stats)}
- for hero_stats in csv_data
- }
-
-
-def _get_hitpoints(hero_stats: dict) -> dict:
- """Extract hitpoints data from hero CSV row"""
- hitpoints = {hp_key: int(hero_stats[hp_key]) for hp_key in HITPOINTS_KEYS}
- hitpoints["total"] = sum(hitpoints.values())
- return hitpoints
-
-
-def parse_heroes_stats() -> dict[str, dict]:
- """
- High-level function to parse heroes stats
-
- Returns:
- Dict mapping hero key to stats
- """
- return parse_heroes_stats_csv()
diff --git a/app/adapters/blizzard/parsers/roles.py b/app/adapters/blizzard/parsers/roles.py
index c379caa1..2e624296 100644
--- a/app/adapters/blizzard/parsers/roles.py
+++ b/app/adapters/blizzard/parsers/roles.py
@@ -14,11 +14,11 @@
from app.roles.helpers import get_role_from_icon_url
if TYPE_CHECKING:
- from app.adapters.blizzard.client import BlizzardClient
+ from app.domain.ports import BlizzardClientPort
async def fetch_roles_html(
- client: BlizzardClient,
+ client: BlizzardClientPort,
locale: Locale = Locale.ENGLISH_US,
) -> str:
"""Fetch roles HTML from Blizzard homepage"""
@@ -86,7 +86,7 @@ def parse_roles_html(html: str) -> list[dict]:
async def parse_roles(
- client: BlizzardClient,
+ client: BlizzardClientPort,
locale: Locale = Locale.ENGLISH_US,
) -> list[dict]:
"""
diff --git a/app/adapters/cache/valkey_cache.py b/app/adapters/cache/valkey_cache.py
index 17b70e93..61e2bb2b 100644
--- a/app/adapters/cache/valkey_cache.py
+++ b/app/adapters/cache/valkey_cache.py
@@ -20,6 +20,7 @@
"""
import json
+import time
from compression import zstd
from functools import wraps
from typing import TYPE_CHECKING, Any
@@ -128,46 +129,48 @@ async def exists(self, key: str) -> bool:
# Application-specific cache methods
@handle_valkey_error(default_return=None)
async def get_api_cache(self, cache_key: str) -> dict | list | None:
- """Get the API Cache value associated with a given cache key"""
+ """Get the API Cache value associated with a given cache key."""
api_cache_key = f"{settings.api_cache_key_prefix}:{cache_key}"
api_cache = await self.valkey_server.get(api_cache_key)
if not api_cache or not isinstance(api_cache, bytes):
return None
- return self._decompress_json_value(api_cache)
+ envelope = self._decompress_json_value(api_cache)
+ if isinstance(envelope, dict) and "data_json" in envelope:
+ return json.loads(envelope["data_json"])
+ return envelope
@handle_valkey_error(default_return=None)
async def update_api_cache(
- self, cache_key: str, value: dict | list, expire: int
+ self,
+ cache_key: str,
+ value: dict | list,
+ expire: int,
+ *,
+ stored_at: int | None = None,
+ staleness_threshold: int | None = None,
+ stale_while_revalidate: int = 0,
) -> None:
- """Update or set an API Cache value with an expiration value (in seconds)"""
- bytes_value = self._compress_json_value(value)
+ """Wrap value in a metadata envelope, compress, and store with TTL.
+
+ ``data_json`` is the pre-serialized JSON string (key order preserved by
+ Python's ``json.dumps``), so nginx/Lua can print it verbatim without
+ re-encoding through cjson (which does not guarantee key ordering).
+ """
+ envelope: dict = {
+ "data_json": json.dumps(value, separators=(",", ":")),
+ "stored_at": stored_at if stored_at is not None else int(time.time()),
+ "staleness_threshold": (
+ staleness_threshold if staleness_threshold is not None else expire
+ ),
+ "stale_while_revalidate": stale_while_revalidate,
+ }
+ bytes_value = self._compress_json_value(envelope)
await self.valkey_server.set(
f"{settings.api_cache_key_prefix}:{cache_key}",
bytes_value,
ex=expire,
)
- @handle_valkey_error(default_return=None)
- async def get_player_cache(self, player_id: str) -> dict | list | None:
- """Get the Player Cache value associated with a given cache key"""
- player_key = f"{settings.player_cache_key_prefix}:{player_id}"
- player_cache = await self.valkey_server.get(player_key)
- if not player_cache or not isinstance(player_cache, bytes):
- return None
- # Reset the TTL before returning the value
- await self.valkey_server.expire(player_key, settings.player_cache_timeout)
- return self._decompress_json_value(player_cache)
-
- @handle_valkey_error(default_return=None)
- async def update_player_cache(self, player_id: str, value: dict) -> None:
- """Update or set a Player Cache value"""
- compressed_value = self._compress_json_value(value)
- await self.valkey_server.set(
- f"{settings.player_cache_key_prefix}:{player_id}",
- value=compressed_value,
- ex=settings.player_cache_timeout,
- )
-
@handle_valkey_error(default_return=False)
async def is_being_rate_limited(self) -> bool:
"""Check if Blizzard rate limit is currently active"""
diff --git a/app/adapters/csv/csv_reader.py b/app/adapters/csv/csv_reader.py
index d6f0a14d..8ee9e55d 100644
--- a/app/adapters/csv/csv_reader.py
+++ b/app/adapters/csv/csv_reader.py
@@ -5,35 +5,11 @@
class CSVReader:
- """Adapter for reading CSV data files"""
+ """Adapter for reading CSV data files from app/adapters/csv/data/"""
@staticmethod
def read_csv_file(filename: str) -> list[dict[str, str]]:
- """
- Read a CSV file from app/adapters/csv/data/ directory
-
- Args:
- filename: Name of the CSV file without extension (e.g., "heroes", "maps", "gamemodes")
-
- Returns:
- List of dictionaries with CSV rows
- """
+ """Read a CSV file by name (without extension) from the data/ directory."""
csv_path = Path(__file__).parent / "data" / f"{filename}.csv"
with csv_path.open(encoding="utf-8") as csv_file:
return list(csv.DictReader(csv_file, delimiter=","))
-
- @staticmethod
- def read_csv_file_legacy(filename: str) -> list[dict[str, str]]:
- """
- Legacy method for reading CSV files from old location app/{module}/data/{module}.csv
- This is kept for backward compatibility during migration.
-
- Args:
- filename: Name of the module/CSV file (e.g., "heroes", "maps", "gamemodes")
-
- Returns:
- List of dictionaries with CSV rows
- """
- csv_path = Path.cwd() / "app" / filename / "data" / f"{filename}.csv"
- with csv_path.open(encoding="utf-8") as csv_file:
- return list(csv.DictReader(csv_file, delimiter=","))
diff --git a/app/adapters/tasks/asyncio_task_queue.py b/app/adapters/tasks/asyncio_task_queue.py
new file mode 100644
index 00000000..4cdae23f
--- /dev/null
+++ b/app/adapters/tasks/asyncio_task_queue.py
@@ -0,0 +1,87 @@
+"""AsyncIO task queue adapter — Phase 4 in-process background tasks with deduplication"""
+
+import asyncio
+import time
+from typing import TYPE_CHECKING, Any, ClassVar
+
+from app.metaclasses import Singleton
+from app.monitoring.metrics import (
+ background_tasks_duration_seconds,
+ background_tasks_queue_size,
+ background_tasks_total,
+)
+from app.overfast_logger import logger
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable, Coroutine
+
+
+class AsyncioTaskQueue(metaclass=Singleton):
+ """In-process task queue backed by asyncio.create_task().
+
+ Uses a class-level set for deduplication so concurrent requests don't
+ trigger duplicate refreshes for the same entity.
+
+ When a ``coro`` is provided to ``enqueue``, it is executed as a real
+ background task. Phase 5 will replace this adapter with an arq-backed
+ one that dispatches to a worker process instead, but the interface stays
+ the same.
+ """
+
+ _pending_jobs: ClassVar[set[str]] = set()
+
+ async def enqueue( # NOSONAR
+ self,
+ task_name: str,
+ *_args: Any,
+ job_id: str | None = None,
+ coro: Coroutine[Any, Any, Any] | None = None,
+ on_complete: Callable[[str], Awaitable[None]] | None = None,
+ on_failure: Callable[[str, Exception], Awaitable[None]] | None = None,
+ **_kwargs: Any,
+ ) -> str:
+ """Schedule a background task if not already pending."""
+ effective_id = job_id or task_name
+ if effective_id in self._pending_jobs:
+ logger.debug(f"[TaskQueue] Skipping duplicate job: {effective_id}")
+ if coro is not None:
+ coro.close() # avoid "coroutine was never awaited" warning
+ return effective_id
+
+ self._pending_jobs.add(effective_id)
+ background_tasks_queue_size.labels(task_type=task_name).inc()
+
+ async def _run() -> None:
+ start = time.monotonic()
+ status = "success"
+ try:
+ logger.info(
+ f"[TaskQueue] Running task '{task_name}' (job_id={effective_id})"
+ )
+ if coro is not None:
+ await coro
+ if on_complete is not None:
+ await on_complete(effective_id)
+ except Exception as exc: # noqa: BLE001
+ status = "failure"
+ logger.warning(
+ f"[TaskQueue] Task '{task_name}' (job_id={effective_id}) failed: {exc}"
+ )
+ if on_failure is not None:
+ await on_failure(effective_id, exc)
+ finally:
+ elapsed = time.monotonic() - start
+ background_tasks_total.labels(task_type=task_name, status=status).inc()
+ background_tasks_duration_seconds.labels(task_type=task_name).observe(
+ elapsed
+ )
+ background_tasks_queue_size.labels(task_type=task_name).dec()
+ self._pending_jobs.discard(effective_id)
+
+ task = asyncio.create_task(_run(), name=effective_id)
+ task.add_done_callback(lambda _: None)
+ return effective_id
+
+ async def is_job_pending_or_running(self, job_id: str) -> bool: # NOSONAR
+ """Return True if a job with this ID is already in-flight."""
+ return job_id in self._pending_jobs
diff --git a/app/api/dependencies.py b/app/api/dependencies.py
index 2b952379..bf6a5b0e 100644
--- a/app/api/dependencies.py
+++ b/app/api/dependencies.py
@@ -5,19 +5,99 @@
from fastapi import Depends
from app.adapters.blizzard.client import BlizzardClient
-from app.cache_manager import CacheManager
+from app.adapters.cache.valkey_cache import ValkeyCache
+from app.adapters.storage.sqlite_storage import SQLiteStorage
+from app.adapters.tasks.asyncio_task_queue import AsyncioTaskQueue
+from app.domain.ports import BlizzardClientPort, CachePort, StoragePort, TaskQueuePort
+from app.domain.services import (
+ GamemodeService,
+ HeroService,
+ MapService,
+ PlayerService,
+ RoleService,
+)
+# ---------------------------------------------------------------------------
+# Low-level adapter dependencies
+# ---------------------------------------------------------------------------
-def get_blizzard_client() -> BlizzardClient:
- """Dependency for Blizzard HTTP client"""
+
+def get_blizzard_client() -> BlizzardClientPort:
+ """Dependency for Blizzard HTTP client (Singleton)."""
return BlizzardClient()
-def get_cache_manager() -> CacheManager:
- """Dependency for cache manager (Valkey)"""
- return CacheManager()
+def get_cache() -> CachePort:
+ """Dependency for Valkey cache (Singleton)."""
+ return ValkeyCache()
+
+
+def get_storage() -> StoragePort:
+ """Dependency for SQLite persistent storage (Singleton)."""
+ return SQLiteStorage()
+
+
+def get_task_queue() -> TaskQueuePort:
+ """Dependency for background task queue (Stub until Phase 5)."""
+ return AsyncioTaskQueue()
+
+
+# ---------------------------------------------------------------------------
+# Service dependencies
+# ---------------------------------------------------------------------------
+
+
+def get_hero_service(
+ cache: CachePort = Depends(get_cache),
+ storage: StoragePort = Depends(get_storage),
+ blizzard_client: BlizzardClientPort = Depends(get_blizzard_client),
+ task_queue: TaskQueuePort = Depends(get_task_queue),
+) -> HeroService:
+ return HeroService(cache, storage, blizzard_client, task_queue)
+
+
+def get_map_service(
+ cache: CachePort = Depends(get_cache),
+ storage: StoragePort = Depends(get_storage),
+ blizzard_client: BlizzardClientPort = Depends(get_blizzard_client),
+ task_queue: TaskQueuePort = Depends(get_task_queue),
+) -> MapService:
+ return MapService(cache, storage, blizzard_client, task_queue)
+
+
+def get_gamemode_service(
+ cache: CachePort = Depends(get_cache),
+ storage: StoragePort = Depends(get_storage),
+ blizzard_client: BlizzardClientPort = Depends(get_blizzard_client),
+ task_queue: TaskQueuePort = Depends(get_task_queue),
+) -> GamemodeService:
+ return GamemodeService(cache, storage, blizzard_client, task_queue)
+
+
+def get_role_service(
+ cache: CachePort = Depends(get_cache),
+ storage: StoragePort = Depends(get_storage),
+ blizzard_client: BlizzardClientPort = Depends(get_blizzard_client),
+ task_queue: TaskQueuePort = Depends(get_task_queue),
+) -> RoleService:
+ return RoleService(cache, storage, blizzard_client, task_queue)
+
+
+def get_player_service(
+ cache: CachePort = Depends(get_cache),
+ storage: StoragePort = Depends(get_storage),
+ blizzard_client: BlizzardClientPort = Depends(get_blizzard_client),
+ task_queue: TaskQueuePort = Depends(get_task_queue),
+) -> PlayerService:
+ return PlayerService(cache, storage, blizzard_client, task_queue)
+
+# ---------------------------------------------------------------------------
+# Type aliases for cleaner router injection
+# ---------------------------------------------------------------------------
-# Type aliases for cleaner dependency injection
-BlizzardClientDep = Annotated[BlizzardClient, Depends(get_blizzard_client)]
-CacheManagerDep = Annotated[CacheManager, Depends(get_cache_manager)]
+HeroServiceDep = Annotated[HeroService, Depends(get_hero_service)]
+MapServiceDep = Annotated[MapService, Depends(get_map_service)]
+GamemodeServiceDep = Annotated[GamemodeService, Depends(get_gamemode_service)]
+RoleServiceDep = Annotated[RoleService, Depends(get_role_service)]
+PlayerServiceDep = Annotated[PlayerService, Depends(get_player_service)]
diff --git a/app/api/routers/gamemodes.py b/app/api/routers/gamemodes.py
index 0528a859..53aae701 100644
--- a/app/api/routers/gamemodes.py
+++ b/app/api/routers/gamemodes.py
@@ -4,10 +4,16 @@
from fastapi import APIRouter, Request, Response
+from app.api.dependencies import GamemodeServiceDep
+from app.config import settings
from app.enums import RouteTag
-from app.gamemodes.controllers.list_gamemodes_controller import ListGamemodesController
from app.gamemodes.models import GamemodeDetails
-from app.helpers import success_responses
+from app.helpers import (
+ apply_swr_headers,
+ build_cache_key,
+ get_human_readable_duration,
+ success_responses,
+)
router = APIRouter()
@@ -19,10 +25,24 @@
summary="Get a list of gamemodes",
description=(
"Get a list of Overwatch gamemodes : Assault, Escort, Flashpoint, Hybrid, etc."
- f"
**Cache TTL : {ListGamemodesController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.csv_cache_timeout)}.**"
),
operation_id="list_map_gamemodes",
response_model=list[GamemodeDetails],
)
-async def list_map_gamemodes(request: Request, response: Response) -> Any:
- return await ListGamemodesController(request, response).process_request()
+async def list_map_gamemodes(
+ request: Request,
+ response: Response,
+ service: GamemodeServiceDep,
+) -> Any:
+ data, is_stale, age = await service.list_gamemodes(
+ cache_key=build_cache_key(request)
+ )
+ apply_swr_headers(
+ response,
+ settings.csv_cache_timeout,
+ is_stale,
+ age,
+ staleness_threshold=settings.gamemodes_staleness_threshold,
+ )
+ return data
diff --git a/app/api/routers/heroes.py b/app/api/routers/heroes.py
index 68c4f9a1..d18e00d8 100644
--- a/app/api/routers/heroes.py
+++ b/app/api/routers/heroes.py
@@ -4,13 +4,15 @@
from fastapi import APIRouter, Path, Query, Request, Response, status
+from app.api.dependencies import HeroServiceDep
+from app.config import settings
from app.enums import Locale, RouteTag
-from app.helpers import routes_responses
-from app.heroes.controllers.get_hero_controller import GetHeroController
-from app.heroes.controllers.get_hero_stats_summary_controller import (
- GetHeroStatsSummaryController,
+from app.helpers import (
+ apply_swr_headers,
+ build_cache_key,
+ get_human_readable_duration,
+ routes_responses,
)
-from app.heroes.controllers.list_heroes_controller import ListHeroesController
from app.heroes.enums import HeroGamemode, HeroKey
from app.heroes.models import (
BadRequestErrorMessage,
@@ -38,7 +40,7 @@
summary="Get a list of heroes",
description=(
"Get a list of Overwatch heroes, which can be filtered using roles or gamemodes. "
- f"
**Cache TTL : {ListHeroesController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.heroes_path_cache_timeout)}.**"
),
operation_id="list_heroes",
response_model=list[HeroShort],
@@ -46,17 +48,24 @@
async def list_heroes(
request: Request,
response: Response,
+ service: HeroServiceDep,
role: Annotated[Role | None, Query(title="Role filter")] = None,
locale: Annotated[
Locale, Query(title="Locale to be displayed")
] = Locale.ENGLISH_US,
gamemode: Annotated[HeroGamemode | None, Query(title="Gamemode filter")] = None,
) -> Any:
- return await ListHeroesController(request, response).process_request(
- role=role,
- locale=locale,
- gamemode=gamemode,
+ data, is_stale, age = await service.list_heroes(
+ locale=locale, role=role, gamemode=gamemode, cache_key=build_cache_key(request)
+ )
+ apply_swr_headers(
+ response,
+ settings.heroes_path_cache_timeout,
+ is_stale,
+ age,
+ staleness_threshold=settings.heroes_staleness_threshold,
)
+ return data
@router.get(
@@ -73,7 +82,7 @@ async def list_heroes(
description=(
"Get hero statistics usage, filtered by platform, region, role, etc."
"Only Role Queue gamemodes are concerned."
- f"
**Cache TTL : {GetHeroStatsSummaryController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.hero_stats_cache_timeout)}.**"
),
operation_id="get_hero_stats",
response_model=list[HeroStatsSummary],
@@ -81,6 +90,7 @@ async def list_heroes(
async def get_hero_stats(
request: Request,
response: Response,
+ service: HeroServiceDep,
platform: Annotated[
PlayerPlatform, Query(title="Player platform filter", examples=["pc"])
],
@@ -88,7 +98,7 @@ async def get_hero_stats(
PlayerGamemode,
Query(
title="Gamemode",
- description=("Filter on a specific gamemode."),
+ description="Filter on a specific gamemode.",
examples=["competitive"],
),
],
@@ -96,7 +106,7 @@ async def get_hero_stats(
PlayerRegion,
Query(
title="Region",
- description=("Filter on a specific player region."),
+ description="Filter on a specific player region.",
examples=["europe"],
),
],
@@ -121,15 +131,23 @@ async def get_hero_stats(
),
] = "hero:asc",
) -> Any:
- return await GetHeroStatsSummaryController(request, response).process_request(
+ data, is_stale, age = await service.get_hero_stats(
platform=platform,
gamemode=gamemode,
region=region,
role=role,
- map=map_,
+ map_filter=map_,
competitive_division=competitive_division,
order_by=order_by,
+ cache_key=build_cache_key(request),
)
+ apply_swr_headers(
+ response,
+ settings.hero_stats_cache_timeout,
+ is_stale,
+ age,
+ )
+ return data
@router.get(
@@ -145,7 +163,7 @@ async def get_hero_stats(
summary="Get hero data",
description=(
"Get data about an Overwatch hero : description, abilities, stadium powers, story, etc. "
- f"
**Cache TTL : {GetHeroController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.hero_path_cache_timeout)}.**"
),
operation_id="get_hero",
response_model=Hero,
@@ -153,12 +171,20 @@ async def get_hero_stats(
async def get_hero(
request: Request,
response: Response,
+ service: HeroServiceDep,
hero_key: Annotated[HeroKey, Path(title="Key name of the hero")], # ty: ignore[invalid-type-form]
locale: Annotated[
Locale, Query(title="Locale to be displayed")
] = Locale.ENGLISH_US,
) -> Any:
- return await GetHeroController(request, response).process_request(
- hero_key=hero_key,
- locale=locale,
+ data, is_stale, age = await service.get_hero(
+ hero_key=str(hero_key), locale=locale, cache_key=build_cache_key(request)
+ )
+ apply_swr_headers(
+ response,
+ settings.hero_path_cache_timeout,
+ is_stale,
+ age,
+ staleness_threshold=settings.heroes_staleness_threshold,
)
+ return data
diff --git a/app/api/routers/maps.py b/app/api/routers/maps.py
index a65c3842..1e6e2083 100644
--- a/app/api/routers/maps.py
+++ b/app/api/routers/maps.py
@@ -4,10 +4,16 @@
from fastapi import APIRouter, Query, Request, Response
+from app.api.dependencies import MapServiceDep
+from app.config import settings
from app.enums import RouteTag
from app.gamemodes.enums import MapGamemode
-from app.helpers import success_responses
-from app.maps.controllers.list_maps_controller import ListMapsController
+from app.helpers import (
+ apply_swr_headers,
+ build_cache_key,
+ get_human_readable_duration,
+ success_responses,
+)
from app.maps.models import Map
router = APIRouter()
@@ -20,7 +26,7 @@
summary="Get a list of maps",
description=(
"Get a list of Overwatch maps : Hanamura, King's Row, Dorado, etc."
- f"
**Cache TTL : {ListMapsController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.csv_cache_timeout)}.**"
),
operation_id="list_maps",
response_model=list[Map],
@@ -28,6 +34,7 @@
async def list_maps(
request: Request,
response: Response,
+ service: MapServiceDep,
gamemode: Annotated[
MapGamemode | None, # ty: ignore[invalid-type-form]
Query(
@@ -36,6 +43,14 @@ async def list_maps(
),
] = None,
) -> Any:
- return await ListMapsController(request, response).process_request(
- gamemode=gamemode
+ data, is_stale, age = await service.list_maps(
+ gamemode=gamemode, cache_key=build_cache_key(request)
+ )
+ apply_swr_headers(
+ response,
+ settings.csv_cache_timeout,
+ is_stale,
+ age,
+ staleness_threshold=settings.maps_staleness_threshold,
)
+ return data
diff --git a/app/api/routers/players.py b/app/api/routers/players.py
index c6895811..e14a7f22 100644
--- a/app/api/routers/players.py
+++ b/app/api/routers/players.py
@@ -4,18 +4,11 @@
from fastapi import APIRouter, Depends, Path, Query, Request, Response, status
+from app.api.dependencies import PlayerServiceDep
+from app.config import settings
from app.enums import RouteTag
+from app.helpers import apply_swr_headers, build_cache_key, get_human_readable_duration
from app.helpers import routes_responses as common_routes_responses
-from app.players.controllers.get_player_career_controller import (
- GetPlayerCareerController,
-)
-from app.players.controllers.get_player_career_stats_controller import (
- GetPlayerCareerStatsController,
-)
-from app.players.controllers.get_player_stats_summary_controller import (
- GetPlayerStatsSummaryController,
-)
-from app.players.controllers.search_players_controller import SearchPlayersController
from app.players.enums import (
HeroKeyCareerFilter,
PlayerGamemode,
@@ -106,7 +99,7 @@ async def get_player_career_common_parameters(
description=(
"Search for a given player by using its username or BattleTag (with # replaced by -). "
"
You should be able to find the associated player_id to use in order to request career data."
- f"
**Cache TTL : {SearchPlayersController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.search_account_path_cache_timeout)}.**"
),
operation_id="search_players",
response_model=PlayerSearchResult,
@@ -114,6 +107,7 @@ async def get_player_career_common_parameters(
async def search_players(
request: Request,
response: Response,
+ service: PlayerServiceDep,
name: Annotated[
str,
Query(
@@ -131,12 +125,16 @@ async def search_players(
offset: Annotated[int, Query(title="Offset of the results", ge=0)] = 0,
limit: Annotated[int, Query(title="Limit of results per page", gt=0)] = 20,
) -> Any:
- return await SearchPlayersController(request, response).process_request(
+ cache_key = build_cache_key(request)
+ data = await service.search_players(
name=name,
order_by=order_by,
offset=offset,
limit=limit,
+ cache_key=cache_key,
)
+ apply_swr_headers(response, settings.search_account_path_cache_timeout, False, 0)
+ return data
@router.get(
@@ -146,7 +144,7 @@ async def search_players(
summary="Get player summary",
description=(
"Get player summary : name, avatar, competitive ranks, etc. "
- f"
**Cache TTL : {GetPlayerCareerController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.career_path_cache_timeout)}.**"
),
operation_id="get_player_summary",
response_model=PlayerSummary,
@@ -154,12 +152,16 @@ async def search_players(
async def get_player_summary(
request: Request,
response: Response,
+ service: PlayerServiceDep,
commons: CommonsPlayerDep,
) -> Any:
- return await GetPlayerCareerController(request, response).process_request(
- summary=True,
- player_id=commons.get("player_id"),
+ cache_key = build_cache_key(request)
+ data, is_stale, age = await service.get_player_summary(
+ player_id=commons["player_id"],
+ cache_key=cache_key,
)
+ apply_swr_headers(response, settings.career_path_cache_timeout, is_stale, age)
+ return data
@router.get(
@@ -176,7 +178,7 @@ async def get_player_summary(
"
Depending on filters, data from both competitive and quickplay, "
"and/or pc and console will be merged."
"
Default behaviour : all gamemodes and platforms are taken in account."
- f"
**Cache TTL : {GetPlayerStatsSummaryController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.career_path_cache_timeout)}.**"
),
operation_id="get_player_stats_summary",
response_model=PlayerStatsSummary,
@@ -184,6 +186,7 @@ async def get_player_summary(
async def get_player_stats_summary(
request: Request,
response: Response,
+ service: PlayerServiceDep,
commons: CommonsPlayerDep,
gamemode: Annotated[
PlayerGamemode | None,
@@ -208,11 +211,15 @@ async def get_player_stats_summary(
),
] = None,
) -> Any:
- return await GetPlayerStatsSummaryController(request, response).process_request(
- player_id=commons.get("player_id"),
- platform=platform,
+ cache_key = build_cache_key(request)
+ data, is_stale, age = await service.get_player_stats_summary(
+ player_id=commons["player_id"],
gamemode=gamemode,
+ platform=platform,
+ cache_key=cache_key,
)
+ apply_swr_headers(response, settings.career_path_cache_timeout, is_stale, age)
+ return data
@router.get(
@@ -226,7 +233,7 @@ async def get_player_stats_summary(
"(combat, game, best, hero specific, average, etc.). Filter them on "
"specific platform and gamemode (mandatory). You can even retrieve "
"data about a specific hero of your choice."
- f"
**Cache TTL : {GetPlayerCareerStatsController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.career_path_cache_timeout)}.**"
),
operation_id="get_player_career_stats",
response_model=PlayerCareerStats,
@@ -234,12 +241,19 @@ async def get_player_stats_summary(
async def get_player_career_stats(
request: Request,
response: Response,
+ service: PlayerServiceDep,
commons: CommonsPlayerCareerDep,
) -> Any:
- return await GetPlayerCareerStatsController(request, response).process_request(
- stats=True,
- **commons,
+ cache_key = build_cache_key(request)
+ data, is_stale, age = await service.get_player_career_stats(
+ player_id=commons["player_id"],
+ gamemode=commons.get("gamemode"),
+ platform=commons.get("platform"),
+ hero=commons.get("hero"),
+ cache_key=cache_key,
)
+ apply_swr_headers(response, settings.career_path_cache_timeout, is_stale, age)
+ return data
@router.get(
@@ -251,7 +265,7 @@ async def get_player_career_stats(
description=(
"This endpoint exposes the same data as the previous one, except it also "
"exposes labels of the categories and statistics."
- f"
**Cache TTL : {GetPlayerCareerController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.career_path_cache_timeout)}.**"
),
operation_id="get_player_stats",
response_model=CareerStats,
@@ -259,12 +273,19 @@ async def get_player_career_stats(
async def get_player_stats(
request: Request,
response: Response,
+ service: PlayerServiceDep,
commons: CommonsPlayerCareerDep,
) -> Any:
- return await GetPlayerCareerController(request, response).process_request(
- stats=True,
- **commons,
+ cache_key = build_cache_key(request)
+ data, is_stale, age = await service.get_player_stats(
+ player_id=commons["player_id"],
+ gamemode=commons.get("gamemode"),
+ platform=commons.get("platform"),
+ hero=commons.get("hero"),
+ cache_key=cache_key,
)
+ apply_swr_headers(response, settings.career_path_cache_timeout, is_stale, age)
+ return data
@router.get(
@@ -274,7 +295,7 @@ async def get_player_stats(
summary="Get all player data",
description=(
"Get all player data : summary and statistics with labels."
- f"
**Cache TTL : {GetPlayerCareerController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.career_path_cache_timeout)}.**"
),
operation_id="get_player_career",
response_model=Player,
@@ -282,6 +303,7 @@ async def get_player_stats(
async def get_player_career(
request: Request,
response: Response,
+ service: PlayerServiceDep,
commons: CommonsPlayerDep,
gamemode: Annotated[
PlayerGamemode | None,
@@ -300,8 +322,12 @@ async def get_player_career(
),
] = None,
) -> Any:
- return await GetPlayerCareerController(request, response).process_request(
- player_id=commons.get("player_id"),
+ cache_key = build_cache_key(request)
+ data, is_stale, age = await service.get_player_career(
+ player_id=commons["player_id"],
gamemode=gamemode,
platform=platform,
+ cache_key=cache_key,
)
+ apply_swr_headers(response, settings.career_path_cache_timeout, is_stale, age)
+ return data
diff --git a/app/api/routers/roles.py b/app/api/routers/roles.py
index 332e5c0b..dc3c41e1 100644
--- a/app/api/routers/roles.py
+++ b/app/api/routers/roles.py
@@ -4,9 +4,15 @@
from fastapi import APIRouter, Query, Request, Response
+from app.api.dependencies import RoleServiceDep
+from app.config import settings
from app.enums import Locale, RouteTag
-from app.helpers import routes_responses
-from app.roles.controllers.list_roles_controller import ListRolesController
+from app.helpers import (
+ apply_swr_headers,
+ build_cache_key,
+ get_human_readable_duration,
+ routes_responses,
+)
from app.roles.models import RoleDetail
router = APIRouter()
@@ -19,7 +25,7 @@
summary="Get a list of roles",
description=(
"Get a list of available Overwatch roles."
- f"
**Cache TTL : {ListRolesController.get_human_readable_timeout()}.**"
+ f"
**Cache TTL : {get_human_readable_duration(settings.heroes_path_cache_timeout)}.**"
),
operation_id="list_roles",
response_model=list[RoleDetail],
@@ -27,8 +33,19 @@
async def list_roles(
request: Request,
response: Response,
+ service: RoleServiceDep,
locale: Annotated[
Locale, Query(title="Locale to be displayed")
] = Locale.ENGLISH_US,
) -> Any:
- return await ListRolesController(request, response).process_request(locale=locale)
+ data, is_stale, age = await service.list_roles(
+ locale=locale, cache_key=build_cache_key(request)
+ )
+ apply_swr_headers(
+ response,
+ settings.heroes_path_cache_timeout,
+ is_stale,
+ age,
+ staleness_threshold=settings.roles_staleness_threshold,
+ )
+ return data
diff --git a/app/cache_manager.py b/app/cache_manager.py
deleted file mode 100644
index f419846e..00000000
--- a/app/cache_manager.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""
-Cache manager module - DEPRECATED, moved to app/adapters/cache/valkey_cache.py
-
-This module is kept for backward compatibility during the migration to v4.
-Import from app.adapters.cache instead.
-"""
-
-from app.adapters.cache import CacheManager
-
-__all__ = ["CacheManager"]
diff --git a/app/config.py b/app/config.py
index ec45d4b0..63059fa9 100644
--- a/app/config.py
+++ b/app/config.py
@@ -133,14 +133,6 @@ class Settings(BaseSettings):
# Used by nginx as main API cache.
api_cache_key_prefix: str = "api-cache"
- # Prefix for keys in Player Cache (Valkey). Used by player classes
- # in order to avoid parsing data which has already been parsed.
- player_cache_key_prefix: str = "player-cache"
-
- # Cache TTL for Player Cache. Whenever a key is accessed, its TTL is reset.
- # It will only expires if not accessed during TTL time.
- player_cache_timeout: int = 259200
-
# Cache TTL for heroes list data (seconds)
heroes_path_cache_timeout: int = 86400
@@ -159,6 +151,26 @@ class Settings(BaseSettings):
# Cache TTL for hero stats data (seconds)
hero_stats_cache_timeout: int = 3600
+ ############
+ # SWR STALENESS THRESHOLDS
+ ############
+
+ # Age (seconds) after which static data is considered stale and triggers
+ # a background refresh while still serving the cached response.
+ heroes_staleness_threshold: int = 86400 # 24 hours
+ maps_staleness_threshold: int = 86400
+ gamemodes_staleness_threshold: int = 86400
+ roles_staleness_threshold: int = 86400
+
+ # Age (seconds) after which a player profile is considered stale.
+ player_staleness_threshold: int = 3600 # 1 hour
+
+ # TTL (seconds) for stale responses written to Valkey API cache.
+ # Short enough that background refresh (typically seconds) will overwrite it
+ # with fresh data before it expires; long enough to absorb burst traffic
+ # while the refresh is in-flight.
+ stale_cache_timeout: int = 60
+
############
# UNKNOWN PLAYERS SYSTEM
############
diff --git a/app/controllers.py b/app/controllers.py
deleted file mode 100644
index fe5ce181..00000000
--- a/app/controllers.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""Abstract API Controller module"""
-
-import json
-from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING
-
-from fastapi import HTTPException, Request, Response
-
-from .adapters.storage import SQLiteStorage
-from .cache_manager import CacheManager
-from .config import settings
-from .exceptions import ParserBlizzardError, ParserParsingError
-from .helpers import get_human_readable_duration, overfast_internal_error
-from .monitoring.metrics import storage_write_errors_total
-from .overfast_logger import logger
-
-if TYPE_CHECKING:
- from .domain.ports import CachePort, StoragePort
-
-
-class AbstractController(ABC):
- """Generic Abstract API Controller, containing attributes structure and methods
- in order to quickly be able to create concrete controllers. A controller can
- be associated with several parsers (one parser = one Blizzard page parsing).
- The API Cache system is handled here.
- """
-
- # Cache manager for Valkey operations (Protocol type for dependency inversion)
- cache_manager: CachePort = CacheManager()
-
- # Storage adapter for persistent data (Protocol type for dependency inversion)
- storage: StoragePort = SQLiteStorage()
-
- def __init__(self, request: Request, response: Response):
- self.cache_key = CacheManager.get_cache_key_from_request(request)
- self.response = response
-
- @property
- @classmethod
- @abstractmethod
- def parser_classes(cls) -> list[type]:
- """Parser classes used for parsing the Blizzard page retrieved with this controller"""
-
- @property
- @classmethod
- @abstractmethod
- def timeout(cls) -> int:
- """Timeout used for API Cache storage for this specific controller"""
-
- @classmethod
- def get_human_readable_timeout(cls) -> str:
- return get_human_readable_duration(cls.timeout)
-
- async def update_static_cache(
- self, data: dict | list, storage_key: str, data_type: str = "json"
- ) -> None:
- """
- Dual-write static data to both Valkey cache and SQLite storage.
-
- Args:
- data: Data to cache (will be JSON-serialized)
- storage_key: Key for SQLite storage (e.g., "heroes:en-us")
- data_type: Type of data ("json" or "html")
- """
- # Update API Cache (Valkey) - async
- await self.cache_manager.update_api_cache(self.cache_key, data, self.timeout)
-
- # Update persistent storage (SQLite) - Phase 3 dual-write
- try:
- json_data = json.dumps(data, separators=(",", ":"))
- await self.storage.set_static_data(
- key=storage_key, data=json_data, data_type=data_type
- )
- except OSError as error:
- # Disk/file I/O errors
- logger.warning(
- f"Storage write failed (disk error) for {storage_key}: {error}"
- )
- self._track_storage_error("disk_error")
- except RuntimeError as error:
- # Compression/serialization errors
- logger.warning(
- f"Storage write failed (compression error) for {storage_key}: {error}"
- )
- self._track_storage_error("compression_error")
- except Exception as error: # noqa: BLE001
- # Unexpected errors
- logger.error(f"Storage write failed (unknown) for {storage_key}: {error}")
- self._track_storage_error("unknown")
-
- @staticmethod
- def _track_storage_error(error_type: str) -> None:
- """Track storage write errors in Prometheus if enabled"""
- if settings.prometheus_enabled:
- storage_write_errors_total.labels(error_type=error_type).inc()
-
- async def process_request(self, **kwargs) -> dict | list:
- """Main method used to process the request from user and return final data. Raises
- an HTTPException in case of error when retrieving or parsing data.
-
- The main steps are :
- - Instanciate the dedicated parser classes in order to retrieve Blizzard data
- - Depending on the parser, an intermediary cache can be used in the process
- - Filter the data using kwargs parameters, then merge the data from parsers
- - Update related API Cache and return the final data
- """
-
- # Instance parsers and request data
- parsers_data = []
- for parser_class in self.parser_classes:
- parser = parser_class(**kwargs)
-
- try:
- await parser.parse()
- except ParserBlizzardError as error:
- raise HTTPException(
- status_code=error.status_code,
- detail=error.message,
- ) from error
- except ParserParsingError as error:
- raise overfast_internal_error(parser.blizzard_url, error) from error
-
- # Filter the data to obtain final parser data
- logger.info("Filtering the data using query...")
- parsers_data.append(parser.filter_request_using_query(**kwargs))
-
- # Merge parsers data together
- computed_data = self.merge_parsers_data(parsers_data, **kwargs)
-
- # Update API Cache - async
- await self.cache_manager.update_api_cache(
- self.cache_key, computed_data, self.timeout
- )
-
- # Ensure response headers contains Cache TTL
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- logger.info("Done ! Returning filtered data...")
- return computed_data
-
- def merge_parsers_data(self, parsers_data: list[dict | list], **_) -> dict | list:
- """Merge parsers data together. It depends on the given route and datas,
- and needs to be overriden in case a given Controller is associated
- with several Parsers.
- """
- return parsers_data[0]
diff --git a/app/domain/models/__init__.py b/app/domain/models/__init__.py
index e69de29b..2ba48204 100644
--- a/app/domain/models/__init__.py
+++ b/app/domain/models/__init__.py
@@ -0,0 +1,11 @@
+"""Domain models package.
+
+Domain model classes (dataclasses or Pydantic models representing core business
+entities independently of FastAPI response schemas) will live here from Phase 5
+onward, when background refresh workers need structured types to pass between
+the task queue and the service layer.
+
+The folder is intentionally empty during Phase 4 — all response shapes are
+currently expressed directly via the existing Pydantic models in each feature
+module (e.g. app/heroes/models.py).
+"""
diff --git a/app/domain/ports/cache.py b/app/domain/ports/cache.py
index fb866ede..5e5ba150 100644
--- a/app/domain/ports/cache.py
+++ b/app/domain/ports/cache.py
@@ -47,30 +47,39 @@ async def get_api_cache(self, cache_key: str) -> dict | list | None:
...
async def update_api_cache(
- self, cache_key: str, value: dict | list, expire: int
+ self,
+ cache_key: str,
+ value: dict | list,
+ expire: int,
+ *,
+ stored_at: int | None = None,
+ staleness_threshold: int | None = None,
+ stale_while_revalidate: int = 0,
) -> None:
- """
- Update or set an API Cache value with an expiration value (in seconds).
-
- Value is JSON-serialized and compressed before storage.
- """
- ...
-
- async def get_player_cache(self, player_id: str) -> dict | list | None:
- """
- Get the Player Cache value associated with a given player ID.
-
- Returns decompressed JSON data or None if not found.
- Resets the TTL on successful retrieval.
- """
- ...
-
- async def update_player_cache(self, player_id: str, value: dict) -> None:
- """
- Update or set a Player Cache value.
-
- Value is JSON-serialized and compressed before storage.
- Uses configured player cache TTL.
+ """Update or set an API Cache value with an expiration value (in seconds).
+
+ Value is wrapped in a metadata envelope before compression::
+
+ {"data_json": "",
+ "stored_at": ,
+ "staleness_threshold": ,
+ "stale_while_revalidate": }
+
+ ``data_json`` is a pre-serialized JSON string so nginx/Lua can print it
+ verbatim without re-encoding through cjson, preserving key ordering.
+ The envelope allows nginx/Lua to set standard ``Age``
+ and ``Cache-Control: stale-while-revalidate`` headers without calling
+ FastAPI.
+
+ Args:
+ cache_key: Valkey key suffix (after ``api-cache:``).
+ value: Data payload to cache.
+ expire: Valkey key TTL in seconds.
+ stored_at: Unix timestamp when the data was generated. Defaults to now.
+ staleness_threshold: Seconds after which the payload is considered stale.
+ Used for ``Cache-Control: max-age``. Defaults to ``expire``.
+ stale_while_revalidate: Seconds nginx may serve stale while revalidating.
+ 0 means no SWR window (omits the directive).
"""
...
diff --git a/app/domain/ports/task_queue.py b/app/domain/ports/task_queue.py
index 6942d16d..df47f9fc 100644
--- a/app/domain/ports/task_queue.py
+++ b/app/domain/ports/task_queue.py
@@ -1,21 +1,38 @@
"""Task queue port protocol for background job processing"""
-from typing import Any, Protocol
+from typing import TYPE_CHECKING, Any, Protocol
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable, Coroutine
class TaskQueuePort(Protocol):
- """Protocol for background task queue operations (arq in v4)"""
+ """Protocol for background task queue operations (arq in Phase 5)"""
async def enqueue(
self,
task_name: str,
*args: Any,
job_id: str | None = None,
+ coro: Coroutine[Any, Any, Any] | None = None,
+ on_complete: Callable[[str], Awaitable[None]] | None = None,
+ on_failure: Callable[[str, Exception], Awaitable[None]] | None = None,
**kwargs: Any,
) -> str:
- """Enqueue a background task, returns job ID"""
+ """Enqueue a background task.
+
+ ``coro``, when provided, is executed immediately (Phase 4 asyncio) or
+ dispatched to a worker process (Phase 5 arq). ``task_name`` is kept for
+ arq compatibility and logging.
+
+ ``on_complete(job_id)`` is awaited when the task finishes successfully.
+ ``on_failure(job_id, exc)`` is awaited when the task raises an exception.
+ Both callbacks are optional and intended for domain-level monitoring.
+
+ Returns the effective job ID.
+ """
...
async def is_job_pending_or_running(self, job_id: str) -> bool:
- """Check if job is already pending or running (for deduplication)"""
+ """Return True if a job with this ID is already pending or running."""
...
diff --git a/app/domain/services/__init__.py b/app/domain/services/__init__.py
index e69de29b..cc79ccac 100644
--- a/app/domain/services/__init__.py
+++ b/app/domain/services/__init__.py
@@ -0,0 +1,17 @@
+"""Domain services — SWR orchestration layer"""
+
+from .base_service import BaseService
+from .gamemode_service import GamemodeService
+from .hero_service import HeroService
+from .map_service import MapService
+from .player_service import PlayerService
+from .role_service import RoleService
+
+__all__ = [
+ "BaseService",
+ "GamemodeService",
+ "HeroService",
+ "MapService",
+ "PlayerService",
+ "RoleService",
+]
diff --git a/app/domain/services/base_service.py b/app/domain/services/base_service.py
new file mode 100644
index 00000000..869ecec5
--- /dev/null
+++ b/app/domain/services/base_service.py
@@ -0,0 +1,122 @@
+"""Domain service base classes.
+
+``BaseService`` holds infrastructure adapters and low-level helpers shared by
+*all* services (static + player).
+
+``StaticDataService`` extends it with the generic Stale-While-Revalidate flow
+for data that is stored as JSON in the ``static_data`` SQLite table and where
+staleness is determined by a configurable time threshold. All static-content
+services (heroes, maps, gamemodes, roles) inherit from this class.
+
+``PlayerService`` inherits directly from ``BaseService`` and implements its own
+staleness strategy (Blizzard ``lastUpdated`` comparison) and storage logic
+(``player_profiles`` table).
+"""
+
+from enum import StrEnum
+from typing import TYPE_CHECKING, Any
+
+from app.monitoring.metrics import (
+ background_refresh_completed_total,
+ background_refresh_failed_total,
+)
+from app.overfast_logger import logger
+
+if TYPE_CHECKING:
+ from collections.abc import Coroutine
+
+ from app.domain.ports import (
+ BlizzardClientPort,
+ CachePort,
+ StoragePort,
+ TaskQueuePort,
+ )
+
+
+class StorageTable(StrEnum):
+ """Persistent storage table identifiers."""
+
+ STATIC_DATA = "static_data"
+ PLAYER_PROFILES = "player_profiles"
+
+
+class BaseService:
+ """Infrastructure holder shared by all domain services.
+
+ Provides:
+ - Adapter references (cache, storage, blizzard_client, task_queue)
+ - ``_update_api_cache``: write to Valkey after serving data
+ - ``_enqueue_refresh``: deduplicated background refresh scheduling
+ """
+
+ def __init__(
+ self,
+ cache: CachePort,
+ storage: StoragePort,
+ blizzard_client: BlizzardClientPort,
+ task_queue: TaskQueuePort,
+ ) -> None:
+ self.cache = cache
+ self.storage = storage
+ self.blizzard_client = blizzard_client
+ self.task_queue = task_queue
+
+ async def _update_api_cache(
+ self,
+ cache_key: str,
+ data: Any,
+ cache_ttl: int,
+ *,
+ staleness_threshold: int | None = None,
+ stale_while_revalidate: int = 0,
+ ) -> None:
+ """Write data to Valkey API cache, swallowing errors."""
+ try:
+ await self.cache.update_api_cache(
+ cache_key,
+ data,
+ cache_ttl,
+ staleness_threshold=staleness_threshold,
+ stale_while_revalidate=stale_while_revalidate,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(f"[SWR] Valkey write failed for {cache_key}: {exc}")
+
+ async def _enqueue_refresh(
+ self,
+ entity_type: str,
+ entity_id: str,
+ refresh_coro: Coroutine[Any, Any, Any] | None = None,
+ ) -> None:
+ """Enqueue a background refresh, deduplicating via job_id.
+
+ ``refresh_coro``, when provided, is passed to the task queue and
+ executed as the actual refresh work (Phase 4: asyncio, Phase 5: arq).
+ Completion and failure are reported via domain-level metrics.
+ """
+ job_id = f"refresh:{entity_type}:{entity_id}"
+
+ async def _on_complete(_job_id: str) -> None: # NOSONAR
+ logger.info(
+ f"[SWR] Background refresh complete for {entity_type}/{entity_id}"
+ )
+ background_refresh_completed_total.labels(entity_type=entity_type).inc()
+
+ async def _on_failure(_job_id: str, exc: Exception) -> None: # NOSONAR
+ logger.warning(f"[SWR] Refresh failed for {entity_type}/{entity_id}: {exc}")
+ background_refresh_failed_total.labels(entity_type=entity_type).inc()
+
+ try:
+ if not await self.task_queue.is_job_pending_or_running(job_id):
+ await self.task_queue.enqueue(
+ f"refresh_{entity_type}",
+ entity_id,
+ job_id=job_id,
+ coro=refresh_coro,
+ on_complete=_on_complete,
+ on_failure=_on_failure,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ f"[SWR] Failed to enqueue refresh for {entity_type}/{entity_id}: {exc}"
+ )
diff --git a/app/domain/services/gamemode_service.py b/app/domain/services/gamemode_service.py
new file mode 100644
index 00000000..bbdb325b
--- /dev/null
+++ b/app/domain/services/gamemode_service.py
@@ -0,0 +1,29 @@
+"""Gamemode domain service — gamemodes list"""
+
+from app.adapters.blizzard.parsers.gamemodes import parse_gamemodes_csv
+from app.config import settings
+from app.domain.services.static_data_service import StaticDataService, StaticFetchConfig
+
+
+class GamemodeService(StaticDataService):
+ """Domain service for gamemode data."""
+
+ async def list_gamemodes(
+ self,
+ cache_key: str,
+ ) -> tuple[list[dict], bool, int]:
+ """Return the gamemodes list."""
+
+ def _fetch() -> list[dict]:
+ return parse_gamemodes_csv()
+
+ return await self.get_or_fetch(
+ StaticFetchConfig(
+ storage_key="gamemodes:all",
+ fetcher=_fetch,
+ cache_key=cache_key,
+ cache_ttl=settings.csv_cache_timeout,
+ staleness_threshold=settings.gamemodes_staleness_threshold,
+ entity_type="gamemodes",
+ )
+ )
diff --git a/app/domain/services/hero_service.py b/app/domain/services/hero_service.py
new file mode 100644
index 00000000..4be734cc
--- /dev/null
+++ b/app/domain/services/hero_service.py
@@ -0,0 +1,208 @@
+"""Hero domain service — heroes list, hero detail, hero stats"""
+
+from typing import TYPE_CHECKING, Any
+
+from fastapi import HTTPException
+
+from app.adapters.blizzard.parsers.hero import parse_hero
+from app.adapters.blizzard.parsers.hero_stats_summary import parse_hero_stats_summary
+from app.adapters.blizzard.parsers.heroes import (
+ fetch_heroes_html,
+ filter_heroes,
+ parse_heroes_html,
+)
+from app.adapters.blizzard.parsers.heroes_hitpoints import parse_heroes_hitpoints
+from app.config import settings
+from app.domain.services.static_data_service import StaticDataService, StaticFetchConfig
+from app.enums import Locale
+from app.exceptions import ParserBlizzardError, ParserParsingError
+from app.helpers import overfast_internal_error
+
+if TYPE_CHECKING:
+ from app.heroes.enums import HeroGamemode
+ from app.maps.enums import MapKey
+ from app.players.enums import (
+ CompetitiveDivisionFilter,
+ PlayerGamemode,
+ PlayerPlatform,
+ PlayerRegion,
+ )
+ from app.roles.enums import Role
+
+
+class HeroService(StaticDataService):
+ """Domain service for hero data: list, detail, and usage statistics."""
+
+ # ------------------------------------------------------------------
+ # Heroes list (GET /heroes)
+ # ------------------------------------------------------------------
+
+ async def list_heroes(
+ self,
+ locale: Locale,
+ role: Role | None,
+ gamemode: HeroGamemode | None,
+ cache_key: str,
+ ) -> tuple[list[dict], bool, int]:
+ """Return the heroes list (with optional role/gamemode filters).
+
+ Stores the *full* (unfiltered) heroes list per locale in SQLite so
+ that all filter combinations benefit from the same cache entry.
+ """
+
+ async def _fetch() -> list[dict]:
+ html = await fetch_heroes_html(self.blizzard_client, locale)
+ return parse_heroes_html(html)
+
+ def _filter(data: list[dict]) -> list[dict]:
+ return filter_heroes(data, role, gamemode)
+
+ return await self.get_or_fetch(
+ StaticFetchConfig(
+ storage_key=f"heroes:{locale}",
+ fetcher=_fetch,
+ result_filter=_filter,
+ cache_key=cache_key,
+ cache_ttl=settings.heroes_path_cache_timeout,
+ staleness_threshold=settings.heroes_staleness_threshold,
+ entity_type="heroes",
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Single hero (GET /heroes/{hero_key})
+ # ------------------------------------------------------------------
+
+ async def get_hero(
+ self,
+ hero_key: str,
+ locale: Locale,
+ cache_key: str,
+ ) -> tuple[dict, bool, int]:
+ """Return full hero details merged with portrait and hitpoints.
+
+ Stores the merged hero data per ``hero_key:locale`` in SQLite so that
+ subsequent requests benefit from the SWR cache and background refresh.
+ """
+
+ async def _fetch() -> dict:
+ try:
+ hero_data = await parse_hero(self.blizzard_client, hero_key, locale)
+ heroes_html = await fetch_heroes_html(self.blizzard_client, locale)
+ heroes_list = parse_heroes_html(heroes_html)
+ heroes_hitpoints = parse_heroes_hitpoints()
+ return _merge_hero_data(
+ hero_data, heroes_list, heroes_hitpoints, hero_key
+ )
+ except ParserBlizzardError as exc:
+ raise HTTPException(
+ status_code=exc.status_code, detail=exc.message
+ ) from exc
+ except ParserParsingError as exc:
+ blizzard_url = f"{settings.blizzard_host}/{locale}{settings.heroes_path}{hero_key}/"
+ raise overfast_internal_error(blizzard_url, exc) from exc
+
+ return await self.get_or_fetch(
+ StaticFetchConfig(
+ storage_key=f"hero:{hero_key}:{locale}",
+ fetcher=_fetch,
+ cache_key=cache_key,
+ cache_ttl=settings.hero_path_cache_timeout,
+ staleness_threshold=settings.heroes_staleness_threshold,
+ entity_type="hero",
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Hero stats summary (GET /heroes/stats)
+ # ------------------------------------------------------------------
+
+ async def get_hero_stats(
+ self,
+ platform: PlayerPlatform,
+ gamemode: PlayerGamemode,
+ region: PlayerRegion,
+ role: Role | None,
+ map_filter: MapKey | None, # ty: ignore[invalid-type-form]
+ competitive_division: CompetitiveDivisionFilter | None, # ty: ignore[invalid-type-form]
+ order_by: str,
+ cache_key: str,
+ ) -> tuple[list[dict], bool, int]:
+ """Return hero usage statistics — Valkey-only cache, no persistent storage.
+
+ Stats change frequently and have too many parameter combinations to
+ store in SQLite. The Valkey API cache (populated here, served by nginx)
+ is sufficient.
+ """
+ try:
+ data = await parse_hero_stats_summary(
+ self.blizzard_client,
+ platform=platform,
+ gamemode=gamemode,
+ region=region,
+ role=role,
+ map_filter=map_filter,
+ competitive_division=competitive_division,
+ order_by=order_by,
+ )
+ except ParserBlizzardError as exc:
+ raise HTTPException(
+ status_code=exc.status_code, detail=exc.message
+ ) from exc
+
+ await self._update_api_cache(
+ cache_key,
+ data,
+ settings.hero_stats_cache_timeout,
+ )
+ return data, False, 0
+
+
+# ---------------------------------------------------------------------------
+# Module-level helpers (kept accessible for tests)
+# ---------------------------------------------------------------------------
+
+
+def _merge_hero_data(
+ hero_data: dict,
+ heroes_list: list[dict],
+ heroes_hitpoints: dict,
+ hero_key: str,
+) -> dict:
+ """Merge data from hero details, heroes list, and heroes hitpoints."""
+ try:
+ portrait_value = next(
+ hero["portrait"] for hero in heroes_list if hero["key"] == hero_key
+ )
+ except StopIteration:
+ portrait_value = None
+ else:
+ hero_data = dict_insert_value_before_key(
+ hero_data, "role", "portrait", portrait_value
+ )
+
+ try:
+ hitpoints = heroes_hitpoints[hero_key]["hitpoints"]
+ except KeyError:
+ hitpoints = None
+ else:
+ hero_data = dict_insert_value_before_key(
+ hero_data, "abilities", "hitpoints", hitpoints
+ )
+
+ return hero_data
+
+
+def dict_insert_value_before_key(
+ data: dict,
+ key: str,
+ new_key: str,
+ new_value: Any,
+) -> dict:
+ """Insert ``new_key: new_value`` before ``key`` in ``data``."""
+ if key not in data:
+ raise KeyError
+ pos = list(data.keys()).index(key)
+ items = list(data.items())
+ items.insert(pos, (new_key, new_value))
+ return dict(items)
diff --git a/app/domain/services/map_service.py b/app/domain/services/map_service.py
new file mode 100644
index 00000000..4efb3053
--- /dev/null
+++ b/app/domain/services/map_service.py
@@ -0,0 +1,40 @@
+"""Map domain service — maps list"""
+
+from app.adapters.blizzard.parsers.maps import parse_maps_csv
+from app.config import settings
+from app.domain.services.static_data_service import StaticDataService, StaticFetchConfig
+
+
+class MapService(StaticDataService):
+ """Domain service for maps data."""
+
+ async def list_maps(
+ self,
+ gamemode: str | None,
+ cache_key: str,
+ ) -> tuple[list[dict], bool, int]:
+ """Return the maps list (with optional gamemode filter).
+
+ Stores the full (unfiltered) maps list in SQLite.
+ """
+
+ def _fetch() -> list[dict]:
+ return parse_maps_csv()
+
+ def _filter(data: list[dict]) -> list[dict]:
+ if not gamemode:
+ return data
+ gamemode_val = gamemode.value if hasattr(gamemode, "value") else gamemode
+ return [m for m in data if gamemode_val in m.get("gamemodes", [])]
+
+ return await self.get_or_fetch(
+ StaticFetchConfig(
+ storage_key="maps:all",
+ fetcher=_fetch,
+ result_filter=_filter,
+ cache_key=cache_key,
+ cache_ttl=settings.csv_cache_timeout,
+ staleness_threshold=settings.maps_staleness_threshold,
+ entity_type="maps",
+ )
+ )
diff --git a/app/domain/services/player_service.py b/app/domain/services/player_service.py
new file mode 100644
index 00000000..8383579b
--- /dev/null
+++ b/app/domain/services/player_service.py
@@ -0,0 +1,561 @@
+"""Player domain service — career, stats, summary, and search"""
+
+import time
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Never, cast
+
+from fastapi import HTTPException, status
+
+from app.adapters.blizzard.parsers.player_career_stats import (
+ parse_player_career_stats_from_html,
+)
+from app.adapters.blizzard.parsers.player_profile import (
+ extract_name_from_profile_html,
+ fetch_player_html,
+ filter_all_stats_data,
+ filter_stats_by_query,
+ parse_player_profile_html,
+)
+from app.adapters.blizzard.parsers.player_search import parse_player_search
+from app.adapters.blizzard.parsers.player_stats import (
+ parse_player_stats_summary_from_html,
+)
+from app.adapters.blizzard.parsers.player_summary import (
+ fetch_player_summary_json,
+ parse_player_summary_json,
+)
+from app.adapters.blizzard.parsers.utils import is_blizzard_id
+from app.config import settings
+from app.domain.services.base_service import BaseService
+from app.exceptions import ParserBlizzardError, ParserParsingError
+from app.helpers import overfast_internal_error
+from app.monitoring.metrics import (
+ sqlite_battletag_lookup_total,
+ sqlite_cache_hit_total,
+ storage_hits_total,
+)
+from app.overfast_logger import logger
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from app.players.enums import (
+ HeroKeyCareerFilter,
+ PlayerGamemode,
+ PlayerPlatform,
+ )
+
+
+@dataclass
+class PlayerIdentity:
+ """Result of player identity resolution.
+
+ Groups the four fields that travel together after resolving a
+ BattleTag or Blizzard ID to a canonical identity.
+ """
+
+ blizzard_id: str | None = field(default=None)
+ player_summary: dict = field(default_factory=dict)
+ cached_html: str | None = field(default=None)
+ battletag_input: str | None = field(default=None)
+
+
+@dataclass
+class PlayerRequest:
+ """Parameter object for a player data request.
+
+ Pass a single ``PlayerRequest`` to ``PlayerService._execute_player_request``
+ instead of passing each field as a separate keyword argument.
+ """
+
+ player_id: str
+ cache_key: str
+ data_factory: Callable[[str, dict], dict]
+
+
+class PlayerService(BaseService):
+ """Domain service for all player-related endpoints.
+
+ Wraps identity resolution, SQLite profile caching, and SWR staleness logic
+ that was previously scattered across multiple controllers.
+ """
+
+ # ------------------------------------------------------------------
+ # Search (Valkey-only, no SQLite, no SWR)
+ # ------------------------------------------------------------------
+
+ async def search_players(
+ self,
+ name: str,
+ order_by: str,
+ offset: int,
+ limit: int,
+ cache_key: str,
+ ) -> dict:
+ """Search for players by name — Valkey-only cache, no persistent storage."""
+ try:
+ data = await parse_player_search(
+ self.blizzard_client,
+ name=name,
+ order_by=order_by,
+ offset=offset,
+ limit=limit,
+ )
+ except ParserParsingError as exc:
+ search_name = name.split("-", 1)[0]
+ blizzard_url = (
+ f"{settings.blizzard_host}{settings.search_account_path}/{search_name}/"
+ )
+ raise overfast_internal_error(blizzard_url, exc) from exc
+
+ await self._update_api_cache(
+ cache_key, data, settings.search_account_path_cache_timeout
+ )
+ return data
+
+ # ------------------------------------------------------------------
+ # Player summary (GET /players/{player_id}/summary)
+ # ------------------------------------------------------------------
+
+ async def get_player_summary(
+ self,
+ player_id: str,
+ cache_key: str,
+ ) -> tuple[dict, bool, int]:
+ """Return player summary (name, avatar, competitive ranks, …)."""
+
+ def extract(html: str, player_summary: dict) -> dict:
+ return parse_player_profile_html(html, player_summary).get("summary") or {}
+
+ return await self._execute_player_request(
+ PlayerRequest(
+ player_id=player_id, cache_key=cache_key, data_factory=extract
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Player career (GET /players/{player_id})
+ # ------------------------------------------------------------------
+
+ async def get_player_career(
+ self,
+ player_id: str,
+ gamemode: PlayerGamemode | None,
+ platform: PlayerPlatform | None,
+ cache_key: str,
+ ) -> tuple[dict, bool, int]:
+ """Return full player data: summary + stats."""
+
+ def extract(html: str, player_summary: dict) -> dict:
+ profile = parse_player_profile_html(html, player_summary)
+ return {
+ "summary": profile.get("summary") or {},
+ "stats": filter_all_stats_data(
+ profile.get("stats") or {}, platform, gamemode
+ ),
+ }
+
+ return await self._execute_player_request(
+ PlayerRequest(
+ player_id=player_id, cache_key=cache_key, data_factory=extract
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Player stats (GET /players/{player_id}/stats)
+ # ------------------------------------------------------------------
+
+ async def get_player_stats(
+ self,
+ player_id: str,
+ gamemode: PlayerGamemode | None,
+ platform: PlayerPlatform | None,
+ hero: HeroKeyCareerFilter | None, # ty: ignore[invalid-type-form]
+ cache_key: str,
+ ) -> tuple[dict, bool, int]:
+ """Return player stats with category labels."""
+
+ def extract(html: str, player_summary: dict) -> dict:
+ profile = parse_player_profile_html(html, player_summary)
+ return filter_stats_by_query(
+ profile.get("stats") or {}, platform, gamemode, hero
+ )
+
+ return await self._execute_player_request(
+ PlayerRequest(
+ player_id=player_id, cache_key=cache_key, data_factory=extract
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Player stats summary (GET /players/{player_id}/stats/summary)
+ # ------------------------------------------------------------------
+
+ async def get_player_stats_summary(
+ self,
+ player_id: str,
+ gamemode: PlayerGamemode | None,
+ platform: PlayerPlatform | None,
+ cache_key: str,
+ ) -> tuple[dict, bool, int]:
+ """Return player statistics summary (winrate, kda, …)."""
+
+ def extract(html: str, player_summary: dict) -> dict:
+ return parse_player_stats_summary_from_html(
+ html, player_summary, gamemode, platform
+ )
+
+ return await self._execute_player_request(
+ PlayerRequest(
+ player_id=player_id, cache_key=cache_key, data_factory=extract
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Player career stats (GET /players/{player_id}/stats/career)
+ # ------------------------------------------------------------------
+
+ async def get_player_career_stats(
+ self,
+ player_id: str,
+ gamemode: PlayerGamemode | None,
+ platform: PlayerPlatform | None,
+ hero: HeroKeyCareerFilter | None, # ty: ignore[invalid-type-form]
+ cache_key: str,
+ ) -> tuple[dict, bool, int]:
+ """Return player career stats (no labels)."""
+
+ def extract(html: str, player_summary: dict) -> dict:
+ return parse_player_career_stats_from_html(
+ html, player_summary, platform, gamemode, hero
+ )
+
+ return await self._execute_player_request(
+ PlayerRequest(
+ player_id=player_id, cache_key=cache_key, data_factory=extract
+ )
+ )
+
+ # ------------------------------------------------------------------
+ # Core request execution — universal scaffold
+ # ------------------------------------------------------------------
+
+ async def _execute_player_request(
+ self, request: PlayerRequest
+ ) -> tuple[dict, bool, int]:
+ """Resolve identity → get HTML → compute data → update cache → return.
+
+ Args:
+ request: ``PlayerRequest`` holding ``player_id``, ``cache_key``,
+ and the endpoint-specific ``data_factory``.
+ """
+ identity = PlayerIdentity()
+ effective_id = request.player_id
+ data: dict = {}
+
+ try:
+ identity = await self._resolve_player_identity(request.player_id)
+ effective_id = identity.blizzard_id or request.player_id
+ html = await self._get_player_html(effective_id, identity)
+ data = request.data_factory(html, identity.player_summary)
+ except Exception as exc: # noqa: BLE001
+ await self._handle_player_exceptions(exc, request.player_id, identity)
+
+ is_stale = self._check_player_staleness()
+ await self._update_api_cache(
+ request.cache_key, data, settings.career_path_cache_timeout
+ )
+ return data, is_stale, 0
+
+ # ------------------------------------------------------------------
+ # Profile caching helpers
+ # ------------------------------------------------------------------
+
+ async def get_player_profile_cache(self, player_id: str) -> dict | None:
+ """Get player profile from SQLite storage."""
+ profile = await self.storage.get_player_profile(player_id)
+ if not profile:
+ if settings.prometheus_enabled:
+ sqlite_cache_hit_total.labels(
+ table="player_profiles", result="miss"
+ ).inc()
+ storage_hits_total.labels(result="miss").inc()
+ return None
+
+ if settings.prometheus_enabled:
+ sqlite_cache_hit_total.labels(table="player_profiles", result="hit").inc()
+ storage_hits_total.labels(result="hit").inc()
+
+ return {
+ "profile": profile["html"],
+ "summary": profile["summary"],
+ "battletag": profile.get("battletag"),
+ "name": profile.get("name"),
+ "updated_at": profile.get("updated_at", 0),
+ }
+
+ async def update_player_profile_cache(
+ self,
+ player_id: str,
+ player_summary: dict,
+ html: str,
+ battletag: str | None = None,
+ name: str | None = None,
+ ) -> None:
+ """Store player profile in SQLite."""
+ await self.storage.set_player_profile(
+ player_id=player_id,
+ html=html,
+ summary=player_summary if player_summary else None,
+ battletag=battletag,
+ name=name,
+ )
+
+ def _check_player_staleness(self) -> bool:
+ """Return is_stale — stub always returning False until Phase 5.
+
+ Phase 5 will perform a real async storage lookup comparing
+ ``player_profiles.updated_at`` against ``player_staleness_threshold``.
+ """
+ return False
+
+ async def _get_player_html(
+ self,
+ effective_id: str,
+ identity: PlayerIdentity,
+ ) -> str:
+ """Return player HTML, always storing fresh HTML in SQLite.
+
+ Priority order:
+ 1. ``identity.cached_html`` — fetched during identity resolution; store and return.
+ 2. SQLite hit with matching ``lastUpdated`` — return cached HTML, backfilling
+ battletag if it was missing.
+ 3. Fetch from Blizzard, store, return.
+ """
+ if identity.cached_html:
+ name = extract_name_from_profile_html(
+ identity.cached_html
+ ) or identity.player_summary.get("name")
+ await self.update_player_profile_cache(
+ effective_id,
+ identity.player_summary,
+ identity.cached_html,
+ identity.battletag_input,
+ name,
+ )
+ return identity.cached_html
+
+ player_cache = await self.get_player_profile_cache(effective_id)
+ if (
+ player_cache is not None
+ and identity.player_summary
+ and player_cache["summary"].get("lastUpdated")
+ == identity.player_summary.get("lastUpdated")
+ ):
+ html = cast("str", player_cache["profile"])
+ if identity.battletag_input and not player_cache.get("battletag"):
+ await self.update_player_profile_cache(
+ effective_id,
+ identity.player_summary,
+ html,
+ identity.battletag_input,
+ player_cache.get("name"),
+ )
+ return html
+
+ html, _ = await fetch_player_html(self.blizzard_client, effective_id)
+ name = extract_name_from_profile_html(html) or identity.player_summary.get(
+ "name"
+ )
+ await self.update_player_profile_cache(
+ effective_id,
+ identity.player_summary,
+ html,
+ identity.battletag_input,
+ name,
+ )
+ return html
+
+ # ------------------------------------------------------------------
+ # Identity resolution
+ # ------------------------------------------------------------------
+
+ async def _resolve_player_identity(self, player_id: str) -> PlayerIdentity:
+ """Resolve BattleTag or Blizzard ID to a canonical ``PlayerIdentity``."""
+ logger.info("Retrieving Player Summary...")
+
+ if is_blizzard_id(player_id):
+ logger.info("Player ID is a Blizzard ID — attempting reverse enrichment")
+ player_summary, html = await self._enrich_from_blizzard_id(player_id)
+ return PlayerIdentity(
+ blizzard_id=player_id,
+ player_summary=player_summary,
+ cached_html=html,
+ )
+
+ battletag_input = player_id
+ search_json = await fetch_player_summary_json(self.blizzard_client, player_id)
+ player_summary = parse_player_summary_json(search_json, player_id)
+
+ if player_summary:
+ logger.info("Player Summary retrieved!")
+ return PlayerIdentity(
+ blizzard_id=player_summary.get("url"),
+ player_summary=player_summary,
+ battletag_input=battletag_input,
+ )
+
+ logger.info(
+ "Player not found in search — checking SQLite for cached Blizzard ID"
+ )
+ cached_blizzard_id = await self.storage.get_player_id_by_battletag(
+ battletag_input
+ )
+
+ if cached_blizzard_id:
+ if settings.prometheus_enabled:
+ sqlite_battletag_lookup_total.labels(result="hit").inc()
+ player_summary = parse_player_summary_json(
+ search_json, player_id, cached_blizzard_id
+ )
+ if player_summary:
+ return PlayerIdentity(
+ blizzard_id=cached_blizzard_id,
+ player_summary=player_summary,
+ battletag_input=battletag_input,
+ )
+ elif settings.prometheus_enabled:
+ sqlite_battletag_lookup_total.labels(result="miss").inc()
+
+ logger.info("No cached mapping — resolving via Blizzard redirect")
+ html, blizzard_id = await fetch_player_html(self.blizzard_client, player_id)
+
+ if blizzard_id and search_json:
+ player_summary = parse_player_summary_json(
+ search_json, player_id, blizzard_id
+ )
+ if player_summary:
+ return PlayerIdentity(
+ blizzard_id=blizzard_id,
+ player_summary=player_summary,
+ cached_html=html,
+ battletag_input=battletag_input,
+ )
+
+ return PlayerIdentity(
+ blizzard_id=blizzard_id,
+ cached_html=html,
+ battletag_input=battletag_input,
+ )
+
+ async def _enrich_from_blizzard_id(
+ self, blizzard_id: str
+ ) -> tuple[dict, str | None]:
+ """Reverse-enrich: fetch HTML → extract name → search for summary."""
+ html, _ = await fetch_player_html(self.blizzard_client, blizzard_id)
+ if not html:
+ return {}, None
+
+ try:
+ player_name = extract_name_from_profile_html(html)
+ if player_name:
+ search_json = await fetch_player_summary_json(
+ self.blizzard_client, player_name
+ )
+ player_summary = parse_player_summary_json(
+ search_json, player_name, blizzard_id
+ )
+ if player_summary:
+ return player_summary, html
+ except Exception as exc: # noqa: BLE001
+ logger.warning(f"Reverse enrichment failed: {exc}")
+
+ return {}, html
+
+ # ------------------------------------------------------------------
+ # Unknown player tracking
+ # ------------------------------------------------------------------
+
+ def _calculate_retry_after(self, check_count: int) -> int:
+ base = settings.unknown_player_initial_retry
+ multiplier = settings.unknown_player_retry_multiplier
+ max_retry = settings.unknown_player_max_retry
+ retry_after = base * (multiplier ** (check_count - 1))
+ return min(int(retry_after), max_retry)
+
+ async def _mark_player_unknown(
+ self,
+ blizzard_id: str,
+ exception: HTTPException,
+ battletag: str | None = None,
+ ) -> None:
+ if not settings.unknown_players_cache_enabled:
+ return
+ if exception.status_code != status.HTTP_404_NOT_FOUND:
+ return
+
+ player_status = await self.cache.get_player_status(blizzard_id)
+ check_count = player_status["check_count"] + 1 if player_status else 1
+ retry_after = self._calculate_retry_after(check_count)
+ next_check_at = int(time.time()) + retry_after
+
+ await self.cache.set_player_status(
+ blizzard_id, check_count, retry_after, battletag=battletag
+ )
+
+ exception.detail = {
+ "error": "Player not found",
+ "retry_after": retry_after,
+ "next_check_at": next_check_at,
+ "check_count": check_count,
+ }
+
+ logger.info(
+ f"Marked player {blizzard_id} as unknown (check #{check_count}, "
+ f"retry in {retry_after}s)"
+ )
+
+ async def _handle_player_exceptions(
+ self,
+ error: Exception,
+ player_id: str,
+ identity: PlayerIdentity,
+ ) -> Never:
+ """Translate all player exceptions to HTTPException and always raise."""
+ effective_id = identity.blizzard_id or player_id
+ battletag_input = identity.battletag_input
+ player_summary = identity.player_summary
+
+ if isinstance(error, ParserBlizzardError):
+ exc = HTTPException(status_code=error.status_code, detail=error.message)
+ if error.status_code == status.HTTP_404_NOT_FOUND:
+ await self._mark_player_unknown(
+ effective_id, exc, battletag=battletag_input
+ )
+ raise exc from error
+
+ if isinstance(error, ParserParsingError):
+ if "Could not find main content in HTML" in str(error):
+ exc = HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Player not found",
+ )
+ await self._mark_player_unknown(
+ effective_id, exc, battletag=battletag_input
+ )
+ raise exc from error
+
+ blizzard_url = (
+ f"{settings.blizzard_host}{settings.career_path}/"
+ f"{player_summary.get('url', effective_id) if player_summary else effective_id}/"
+ )
+ raise overfast_internal_error(blizzard_url, error) from error
+
+ if isinstance(error, HTTPException):
+ if error.status_code == status.HTTP_404_NOT_FOUND:
+ await self._mark_player_unknown(
+ effective_id, error, battletag=battletag_input
+ )
+ raise error
+
+ raise error
diff --git a/app/domain/services/role_service.py b/app/domain/services/role_service.py
new file mode 100644
index 00000000..fe7d4868
--- /dev/null
+++ b/app/domain/services/role_service.py
@@ -0,0 +1,38 @@
+"""Role domain service — roles list"""
+
+from app.adapters.blizzard.parsers.roles import fetch_roles_html, parse_roles_html
+from app.config import settings
+from app.domain.services.static_data_service import StaticDataService, StaticFetchConfig
+from app.enums import Locale
+from app.exceptions import ParserParsingError
+from app.helpers import overfast_internal_error
+
+
+class RoleService(StaticDataService):
+ """Domain service for role data."""
+
+ async def list_roles(
+ self,
+ locale: Locale,
+ cache_key: str,
+ ) -> tuple[list[dict], bool, int]:
+ """Return the roles list."""
+
+ async def _fetch() -> list[dict]:
+ try:
+ html = await fetch_roles_html(self.blizzard_client, locale)
+ return parse_roles_html(html)
+ except ParserParsingError as exc:
+ blizzard_url = f"{settings.blizzard_host}/{locale}{settings.home_path}"
+ raise overfast_internal_error(blizzard_url, exc) from exc
+
+ return await self.get_or_fetch(
+ StaticFetchConfig(
+ storage_key=f"roles:{locale}",
+ fetcher=_fetch,
+ cache_key=cache_key,
+ cache_ttl=settings.heroes_path_cache_timeout,
+ staleness_threshold=settings.roles_staleness_threshold,
+ entity_type="roles",
+ )
+ )
diff --git a/app/domain/services/static_data_service.py b/app/domain/services/static_data_service.py
new file mode 100644
index 00000000..e27b2502
--- /dev/null
+++ b/app/domain/services/static_data_service.py
@@ -0,0 +1,174 @@
+import inspect
+import json
+import time
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+from app.config import settings
+from app.domain.services import BaseService
+from app.monitoring.metrics import (
+ background_refresh_triggered_total,
+ stale_responses_total,
+ storage_hits_total,
+)
+from app.overfast_logger import logger
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+@dataclass
+class StaticFetchConfig:
+ """Parameter object grouping all inputs needed for a static SWR fetch.
+
+ Pass a single ``StaticFetchConfig`` to ``StaticDataService.get_or_fetch``
+ instead of passing each field as a separate keyword argument.
+ """
+
+ storage_key: str
+ fetcher: Callable[[], Any]
+ cache_key: str
+ cache_ttl: int
+ staleness_threshold: int
+ entity_type: str
+ parser: Callable[[Any], Any] | None = field(default=None)
+ result_filter: Callable[[Any], Any] | None = field(default=None)
+
+
+class StaticDataService(BaseService):
+ """SWR orchestration for static content backed by the ``static_data`` SQLite table.
+
+ Staleness is determined by a configurable time threshold. Concrete static
+ services (heroes, maps, gamemodes, roles) call ``get_or_fetch`` with a
+ ``StaticFetchConfig`` — no subclass-level overrides are needed for the
+ storage layer.
+
+ Note: Valkey API-cache *reads* happen at the Nginx/Lua layer before FastAPI
+ is reached; this service only ever *writes* to the API cache.
+ """
+
+ async def get_or_fetch(self, config: StaticFetchConfig) -> tuple[Any, bool, int]:
+ """SWR orchestration for static data.
+
+ Returns:
+ ``(data, is_stale, age_seconds)`` tuple. ``age_seconds`` is the
+ number of seconds since the data was last stored in SQLite (0 on
+ a cold-start fetch).
+ """
+ stored = await self._load_from_storage(config.storage_key)
+ if stored is not None:
+ storage_hits_total.labels(result="hit").inc()
+ return await self._serve_from_storage(stored, config)
+
+ storage_hits_total.labels(result="miss").inc()
+ return await self._cold_fetch(config)
+
+ async def _load_from_storage(self, storage_key: str) -> dict[str, Any] | None:
+ """Load data from the ``static_data`` SQLite table. Returns ``None`` on miss."""
+ result = await self.storage.get_static_data(storage_key)
+ return (
+ {
+ "data": json.loads(result["data"]),
+ "updated_at": result["updated_at"],
+ }
+ if result
+ else None
+ )
+
+ async def _serve_from_storage(
+ self, stored: dict[str, Any], config: StaticFetchConfig
+ ) -> tuple[Any, bool, int]:
+ """Serve data from a SQLite hit, triggering a background refresh if stale."""
+ data = stored["data"]
+ age = int(time.time()) - stored["updated_at"]
+ is_stale = age >= config.staleness_threshold
+ filtered = self._apply_filter(data, config.result_filter)
+
+ if is_stale:
+ logger.info(
+ f"[SWR] {config.entity_type} stale (age={age}s, "
+ f"threshold={config.staleness_threshold}s) — serving + triggering refresh"
+ )
+ await self._enqueue_refresh(
+ config.entity_type,
+ config.storage_key,
+ refresh_coro=self._refresh_static(config),
+ )
+ stale_responses_total.inc()
+ background_refresh_triggered_total.labels(
+ entity_type=config.entity_type
+ ).inc()
+ # Short TTL absorbs burst traffic while the background refresh is in-flight.
+ await self._update_api_cache(
+ config.cache_key,
+ filtered,
+ settings.stale_cache_timeout,
+ staleness_threshold=config.staleness_threshold,
+ stale_while_revalidate=settings.stale_cache_timeout,
+ )
+ else:
+ logger.info(
+ f"[SWR] {config.entity_type} fresh (age={age}s) — serving from SQLite"
+ )
+ await self._update_api_cache(
+ config.cache_key,
+ filtered,
+ config.cache_ttl,
+ staleness_threshold=config.staleness_threshold,
+ )
+
+ return filtered, is_stale, age
+
+ @staticmethod
+ def _apply_filter(data: Any, result_filter: Callable[[Any], Any] | None) -> Any:
+ """Apply ``result_filter`` to ``data`` if provided, otherwise return as-is."""
+ return result_filter(data) if result_filter is not None else data
+
+ async def _refresh_static(self, config: StaticFetchConfig) -> None:
+ """Fetch fresh data, persist to SQLite and update Valkey with full TTL.
+
+ Exceptions are intentionally not caught here — they propagate to the
+ task queue adapter which calls the ``on_failure`` callback for metrics.
+ """
+ logger.info(
+ f"[SWR] Background refresh started for"
+ f" {config.entity_type}/{config.storage_key}"
+ )
+ await self._fetch_and_store(config)
+
+ async def _fetch_and_store(self, config: StaticFetchConfig) -> Any:
+ """Fetch from source, persist to SQLite, update Valkey, return filtered data."""
+ if inspect.iscoroutinefunction(config.fetcher):
+ raw = await config.fetcher()
+ else:
+ raw = config.fetcher()
+
+ data = config.parser(raw) if config.parser is not None else raw
+ await self._store_in_storage(config.storage_key, data)
+
+ filtered = self._apply_filter(data, config.result_filter)
+ await self._update_api_cache(
+ config.cache_key,
+ filtered,
+ config.cache_ttl,
+ staleness_threshold=config.staleness_threshold,
+ )
+
+ return filtered
+
+ async def _cold_fetch(self, config: StaticFetchConfig) -> tuple[Any, bool, int]:
+ """Fetch from source on cold start, persist to SQLite and Valkey."""
+ logger.info(f"[SWR] {config.entity_type} not in SQLite — fetching from source")
+ filtered = await self._fetch_and_store(config)
+ return filtered, False, 0
+
+ async def _store_in_storage(self, storage_key: str, data: Any) -> None:
+ """Persist data to the ``static_data`` SQLite table as JSON."""
+ try:
+ await self.storage.set_static_data(
+ key=storage_key,
+ data=json.dumps(data, separators=(",", ":")),
+ data_type="json",
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(f"[SWR] SQLite write failed for {storage_key}: {exc}")
diff --git a/app/gamemodes/controllers/list_gamemodes_controller.py b/app/gamemodes/controllers/list_gamemodes_controller.py
deleted file mode 100644
index 84c07d8a..00000000
--- a/app/gamemodes/controllers/list_gamemodes_controller.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""List Gamemodes Controller module"""
-
-from typing import ClassVar
-
-from app.adapters.blizzard.parsers.gamemodes import parse_gamemodes
-from app.config import settings
-from app.controllers import AbstractController
-
-
-class ListGamemodesController(AbstractController):
- """List Gamemodes Controller used in order to retrieve a list of
- available Overwatch gamemodes.
- """
-
- parser_classes: ClassVar[list[type]] = []
- timeout = settings.csv_cache_timeout
-
- async def process_request(self, **kwargs) -> list[dict]: # noqa: ARG002
- """Process request using stateless parser function"""
- data = parse_gamemodes()
-
- # Dual-write to API Cache (Valkey) and Storage (SQLite)
- storage_key = "gamemodes:en-us" # Gamemodes are not localized
- await self.update_static_cache(data, storage_key, data_type="json")
-
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
diff --git a/app/gamemodes/data/gamemodes.csv b/app/gamemodes/data/gamemodes.csv
deleted file mode 100644
index e74a8943..00000000
--- a/app/gamemodes/data/gamemodes.csv
+++ /dev/null
@@ -1,15 +0,0 @@
-key,name,description
-assault,Assault,"Teams fight to capture or defend two successive points against the enemy team. It's an inactive Overwatch 1 gamemode, also called 2CP."
-capture-the-flag,Capture the Flag,Teams compete to capture the enemy team’s flag while defending their own.
-clash,Clash,"Vie for dominance across a series of capture points with dynamic spawns and linear map routes, so you spend less time running back to the battle and more time in the heart of it."
-control,Control,Teams fight to hold a single objective. The first team to win two rounds wins the map.
-deathmatch,Deathmatch,Race to reach 20 points first by racking up kills in a free-for-all format.
-elimination,Elimination,"Dispatch all enemies to win the round. Win three rounds to claim victory. Available with teams of one, three, or six."
-escort,Escort,"One team escorts a payload to its delivery point, while the other races to stop them."
-flashpoint,Flashpoint,"Teams fight across our biggest PVP maps to date, New Junk City and Suravasa, to seize control of five different objectives in a fast-paced, best-of-five battle!"
-hybrid,Hybrid,"Attackers capture a payload, then escort it to its destination; defenders try to hold them back."
-payload-race,Payload Race,"Both teams get a payload to escort to the ending location while preventing the enemies from doing the same."
-practice-range,Practice Range,"Learn the basics, practice and test your settings."
-push,Push,Teams battle to take control of a robot and push it toward the enemy base.
-team-deathmatch,Team Deathmatch,Team up and triumph over your enemies by scoring the most kills.
-workshop,Workshop,"Experience custom and experimental gameplay, only in Custom Games."
\ No newline at end of file
diff --git a/app/gamemodes/enums.py b/app/gamemodes/enums.py
index 5614f6b8..3cf8bda9 100644
--- a/app/gamemodes/enums.py
+++ b/app/gamemodes/enums.py
@@ -1,9 +1,9 @@
from enum import StrEnum
-from app.helpers import read_csv_data_file
+from app.adapters.csv import CSVReader
# Dynamically create the MapGamemode enum by using the CSV File
-gamemodes_data = read_csv_data_file("gamemodes")
+gamemodes_data = CSVReader.read_csv_file("gamemodes")
MapGamemode = StrEnum(
"MapGamemode",
{
diff --git a/app/gamemodes/parsers/__init__.py b/app/gamemodes/parsers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/gamemodes/parsers/gamemodes_parser.py b/app/gamemodes/parsers/gamemodes_parser.py
deleted file mode 100644
index 90c39e42..00000000
--- a/app/gamemodes/parsers/gamemodes_parser.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Gamemodes Parser module"""
-
-from app.parsers import CSVParser
-
-
-class GamemodesParser(CSVParser):
- """Overwatch map gamemodes list page Parser class"""
-
- filename = "gamemodes"
-
- def parse_data(self) -> list[dict]:
- return [
- {
- "key": gamemode["key"],
- "name": gamemode["name"],
- "icon": self.get_static_url(
- f"{gamemode['key']}-icon",
- extension="svg",
- ),
- "description": gamemode["description"],
- "screenshot": self.get_static_url(
- gamemode["key"],
- extension="avif",
- ),
- }
- for gamemode in self.csv_data
- ]
diff --git a/app/helpers.py b/app/helpers.py
index 4cb3903b..ba7eb99c 100644
--- a/app/helpers.py
+++ b/app/helpers.py
@@ -1,10 +1,9 @@
"""Parser Helpers module"""
-import csv
import traceback
from datetime import UTC, datetime
from functools import cache
-from pathlib import Path
+from typing import TYPE_CHECKING
import httpx
from fastapi import HTTPException, status
@@ -18,6 +17,9 @@
)
from .overfast_logger import logger
+if TYPE_CHECKING:
+ from fastapi import Request, Response
+
# Typical routes responses to return
success_responses = {
status.HTTP_200_OK: {
@@ -30,6 +32,42 @@
"example": "600",
},
},
+ "Age": {
+ "description": (
+ "Number of seconds since the response payload was generated "
+ "(RFC 7234 §5.1). Present on FastAPI-served responses; "
+ "also set by nginx for Valkey cache hits."
+ ),
+ "schema": {
+ "type": "string",
+ "example": "42",
+ },
+ },
+ "Cache-Control": {
+ "description": (
+ "Standard caching directives (RFC 7234 + RFC 5861). "
+ "``max-age`` reflects the staleness threshold in seconds. "
+ "``stale-while-revalidate`` is present when a background "
+ "refresh is in-flight, indicating how long stale data may "
+ "still be served."
+ ),
+ "schema": {
+ "type": "string",
+ "example": "public, max-age=86400, stale-while-revalidate=60",
+ },
+ },
+ "X-Cache-Status": {
+ "description": (
+ "Indicates whether the response was served from a fresh "
+ "cache entry (``hit``) or a stale one while a background "
+ "refresh is in-flight (``stale``)."
+ ),
+ "schema": {
+ "type": "string",
+ "enum": ["hit", "stale"],
+ "example": "hit",
+ },
+ },
},
},
}
@@ -199,15 +237,6 @@ def send_discord_webhook_message(
)
-@cache
-def read_csv_data_file(filename: str) -> list[dict[str, str]]:
- """Helper method for obtaining CSV DictReader from a path"""
- with Path(f"{Path.cwd()}/app/{filename}/data/{filename}.csv").open(
- encoding="utf-8"
- ) as csv_file:
- return list(csv.DictReader(csv_file, delimiter=","))
-
-
@cache
def get_human_readable_duration(duration: int) -> str:
# Define the time units
@@ -225,3 +254,47 @@ def get_human_readable_duration(duration: int) -> str:
duration_parts.append(f"{minutes} minute{'s' if minutes > 1 else ''}")
return ", ".join(duration_parts)
+
+
+def build_cache_key(request: Request) -> str:
+ """Build a canonical cache key from the request URL path + query string.
+
+ Uses the raw query string (``request.url.query``) to preserve the original
+ percent-encoding, so the key matches what nginx stores in
+ ``api-cache:`` exactly.
+ """
+ qs = request.url.query
+ return f"{request.url.path}?{qs}" if qs else request.url.path
+
+
+def apply_swr_headers(
+ response: Response,
+ cache_ttl: int,
+ is_stale: bool,
+ age_seconds: int = 0,
+ *,
+ staleness_threshold: int | None = None,
+) -> None:
+ """Add standard SWR and cache metadata headers to the response.
+
+ Sets ``Cache-Control`` (RFC 5861), ``Age`` (RFC 7234), ``X-Cache-Status``,
+ and the non-standard ``X-Cache-TTL`` on every FastAPI-served response.
+
+ ``staleness_threshold`` is used for ``Cache-Control: max-age``; it defaults
+ to ``cache_ttl`` for endpoints that have no SWR (e.g. player, search).
+ ``stale-while-revalidate`` is included only on stale responses, using the
+ configured ``stale_cache_timeout`` as the revalidation window.
+ """
+ max_age = staleness_threshold if staleness_threshold is not None else cache_ttl
+ response.headers[settings.cache_ttl_header] = str(cache_ttl)
+ if age_seconds > 0:
+ response.headers["Age"] = str(age_seconds)
+ if is_stale:
+ response.headers["Cache-Control"] = (
+ f"public, max-age={max_age},"
+ f" stale-while-revalidate={settings.stale_cache_timeout}"
+ )
+ response.headers["X-Cache-Status"] = "stale"
+ else:
+ response.headers["Cache-Control"] = f"public, max-age={max_age}"
+ response.headers["X-Cache-Status"] = "hit"
diff --git a/app/heroes/commands/check_new_hero.py b/app/heroes/commands/check_new_hero.py
index 6941ee50..0acb3fae 100644
--- a/app/heroes/commands/check_new_hero.py
+++ b/app/heroes/commands/check_new_hero.py
@@ -7,29 +7,25 @@
from fastapi import HTTPException
+from app.adapters.blizzard import OverFastClient
+from app.adapters.blizzard.parsers.heroes import fetch_heroes_html, parse_heroes_html
from app.config import settings
from app.exceptions import ParserParsingError
from app.helpers import send_discord_webhook_message
-from app.overfast_client import OverFastClient
from app.overfast_logger import logger
from ..enums import HeroKey
-from ..parsers.heroes_parser import HeroesParser
async def get_distant_hero_keys(client: OverFastClient) -> set[str]:
"""Get a set of Overwatch hero keys from the Blizzard heroes page"""
- heroes_parser = HeroesParser(client=client)
-
try:
- await heroes_parser.parse()
+ html = await fetch_heroes_html(client)
+ heroes = parse_heroes_html(html)
except (HTTPException, ParserParsingError) as error:
raise SystemExit from error
- if not isinstance(heroes_parser.data, list):
- raise SystemExit
-
- return {hero["key"] for hero in heroes_parser.data}
+ return {hero["key"] for hero in heroes}
def get_local_hero_keys() -> set[str]:
@@ -46,7 +42,6 @@ async def main():
logger.info("OK ! Starting to check if a new hero is here...")
- # Instanciate one HTTPX Client to use for all the updates
client = OverFastClient()
distant_hero_keys = await get_distant_hero_keys(client)
@@ -54,7 +49,6 @@ async def main():
await client.aclose()
- # Compare both sets. If we have a difference, notify the developer
new_hero_keys = distant_hero_keys - local_hero_keys
if len(new_hero_keys) > 0:
logger.info("New hero keys were found : {}", new_hero_keys)
@@ -73,7 +67,7 @@ async def main():
"inline": False,
},
],
- color=0x2ECC71, # Green
+ color=0x2ECC71,
)
else:
logger.info("No new hero found. Exiting.")
diff --git a/app/heroes/controllers/__init__.py b/app/heroes/controllers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/heroes/controllers/get_hero_controller.py b/app/heroes/controllers/get_hero_controller.py
deleted file mode 100644
index b23aa8d5..00000000
--- a/app/heroes/controllers/get_hero_controller.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""Hero Controller module"""
-
-from typing import Any, ClassVar
-
-from fastapi import HTTPException
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.hero import parse_hero
-from app.adapters.blizzard.parsers.heroes import parse_heroes
-from app.adapters.blizzard.parsers.heroes_stats import parse_heroes_stats
-from app.config import settings
-from app.controllers import AbstractController
-from app.enums import Locale
-from app.exceptions import ParserBlizzardError, ParserParsingError
-from app.helpers import overfast_internal_error
-
-
-class GetHeroController(AbstractController):
- """Hero Controller used in order to retrieve data about a single
- Overwatch hero.
- """
-
- parser_classes: ClassVar[list[type]] = []
- timeout = settings.hero_path_cache_timeout
-
- async def process_request(self, **kwargs) -> dict:
- """Process request using stateless parser functions"""
- hero_key = kwargs["hero_key"]
- locale = kwargs.get("locale") or Locale.ENGLISH_US
-
- client = BlizzardClient()
-
- try:
- # Fetch data from all three sources
- hero_data = await parse_hero(client, str(hero_key), locale)
- heroes_list = await parse_heroes(client, locale)
- heroes_stats = parse_heroes_stats()
-
- # Merge data
- data = self._merge_hero_data(hero_data, heroes_list, heroes_stats, hero_key)
- except ParserBlizzardError as error:
- raise HTTPException(
- status_code=error.status_code,
- detail=error.message,
- ) from error
- except ParserParsingError as error:
- # Get Blizzard URL for error reporting
- blizzard_url = (
- f"{settings.blizzard_host}/{locale}{settings.heroes_path}{hero_key}/"
- )
- raise overfast_internal_error(blizzard_url, error) from error
-
- # Update API Cache
- await self.cache_manager.update_api_cache(self.cache_key, data, self.timeout)
-
- # Note: Single hero data is not stored in persistent storage (Phase 3)
- # It's derived from heroes list + hero details, both of which are cached separately
-
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
-
- @staticmethod
- def _merge_hero_data(
- hero_data: dict,
- heroes_list: list[dict],
- heroes_stats: dict,
- hero_key: str,
- ) -> dict:
- """
- Merge data from hero details, heroes list, and heroes stats
-
- Args:
- hero_data: Detailed hero data from hero parser
- heroes_list: List of all heroes (for portrait)
- heroes_stats: Stats dict (for hitpoints)
- hero_key: Hero key for lookups
- """
- # Add portrait from heroes list
- try:
- portrait_value = next(
- hero["portrait"] for hero in heroes_list if hero["key"] == hero_key
- )
- except StopIteration:
- # The hero key may not be here in some specific edge cases,
- # for example if the hero has been released but is not in the
- # heroes list yet, or the list cache is outdated
- portrait_value = None
- else:
- hero_data = dict_insert_value_before_key(
- hero_data,
- "role",
- "portrait",
- portrait_value,
- )
-
- # Add hitpoints from stats
- try:
- hitpoints = heroes_stats[hero_key]["hitpoints"]
- except KeyError:
- # Hero hitpoints may not be here if the CSV file
- # containing the data hasn't been updated
- hitpoints = None
- else:
- hero_data = dict_insert_value_before_key(
- hero_data,
- "abilities",
- "hitpoints",
- hitpoints,
- )
-
- return hero_data
-
- # Keep legacy method name for backward compatibility with tests
- @staticmethod
- def _dict_insert_value_before_key(
- data: dict,
- key: str,
- new_key: str,
- new_value: Any,
- ) -> dict:
- """Backward compatibility wrapper for tests"""
- return dict_insert_value_before_key(data, key, new_key, new_value)
-
-
-def dict_insert_value_before_key(
- data: dict,
- key: str,
- new_key: str,
- new_value: Any,
-) -> dict:
- """Insert a given key/value pair before another key in a given dict"""
- if key not in data:
- raise KeyError
-
- key_pos = list(data.keys()).index(key)
- data_items = list(data.items())
- data_items.insert(key_pos, (new_key, new_value))
-
- return dict(data_items)
diff --git a/app/heroes/controllers/get_hero_stats_summary_controller.py b/app/heroes/controllers/get_hero_stats_summary_controller.py
deleted file mode 100644
index d6e39b4d..00000000
--- a/app/heroes/controllers/get_hero_stats_summary_controller.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Hero Stats Summary Controller module"""
-
-from typing import ClassVar
-
-from fastapi import HTTPException
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.hero_stats_summary import parse_hero_stats_summary
-from app.config import settings
-from app.controllers import AbstractController
-from app.exceptions import ParserBlizzardError
-
-
-class GetHeroStatsSummaryController(AbstractController):
- """Get Hero Stats Summary Controller used in order to
- retrieve usage statistics for Overwatch heroes.
- """
-
- parser_classes: ClassVar[list[type]] = []
- timeout = settings.hero_stats_cache_timeout
-
- async def process_request(self, **kwargs) -> list[dict]:
- """Process request using stateless parser function"""
- client = BlizzardClient()
-
- try:
- data = await parse_hero_stats_summary(
- client,
- platform=kwargs["platform"],
- gamemode=kwargs["gamemode"],
- region=kwargs["region"],
- role=kwargs.get("role"),
- map_filter=kwargs.get("map"),
- competitive_division=kwargs.get("competitive_division"),
- order_by=kwargs.get("order_by", "hero:asc"),
- )
- except ParserBlizzardError as error:
- raise HTTPException(
- status_code=error.status_code,
- detail=error.message,
- ) from error
-
- # Dual-write to API Cache (Valkey) and Storage (SQLite)
- storage_key = self._build_storage_key(**kwargs)
- await self.update_static_cache(data, storage_key, data_type="json")
-
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
-
- @staticmethod
- def _build_storage_key(**kwargs) -> str:
- """Build parameterized storage key for hero stats"""
- platform = kwargs["platform"].value
- gamemode = kwargs["gamemode"].value
- region = kwargs["region"].value
- map_filter = kwargs.get("map", "all-maps")
- tier = kwargs.get("competitive_division", "null")
- return f"hero_stats:{platform}:{gamemode}:{region}:{map_filter}:{tier}"
diff --git a/app/heroes/controllers/list_heroes_controller.py b/app/heroes/controllers/list_heroes_controller.py
deleted file mode 100644
index 0bd65f93..00000000
--- a/app/heroes/controllers/list_heroes_controller.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""List Heroes Controller module"""
-
-from typing import ClassVar
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.heroes import parse_heroes
-from app.config import settings
-from app.controllers import AbstractController
-from app.enums import Locale
-from app.exceptions import ParserParsingError
-from app.helpers import overfast_internal_error
-
-
-class ListHeroesController(AbstractController):
- """List Heroes Controller used in order to
- retrieve a list of available Overwatch heroes.
- """
-
- # Keep parser_classes for backward compatibility, but it's no longer used
- parser_classes: ClassVar[list[type]] = []
- timeout = settings.heroes_path_cache_timeout
-
- async def process_request(self, **kwargs) -> list[dict]:
- """Process request using stateless parser function"""
- # Extract params
- role = kwargs.get("role")
- locale = kwargs.get("locale") or Locale.ENGLISH_US
- gamemode = kwargs.get("gamemode")
-
- # Use stateless parser function
- client = BlizzardClient()
-
- try:
- data = await parse_heroes(
- client, locale=locale, role=role, gamemode=gamemode
- )
- except ParserParsingError as error:
- # Get Blizzard URL for error reporting
- blizzard_url = f"{settings.blizzard_host}/{locale}{settings.heroes_path}"
- raise overfast_internal_error(blizzard_url, error) from error
-
- # Dual-write to API Cache (Valkey) and Storage (SQLite)
- storage_key = f"heroes:{locale}"
- await self.update_static_cache(data, storage_key, data_type="json")
-
- # Ensure response headers contains Cache TTL
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
diff --git a/app/heroes/data/heroes.csv b/app/heroes/data/heroes.csv
deleted file mode 100644
index f56c2394..00000000
--- a/app/heroes/data/heroes.csv
+++ /dev/null
@@ -1,51 +0,0 @@
-key,name,role,health,armor,shields
-ana,Ana,support,250,0,0
-anran,Anran,damage,250,0,0
-ashe,Ashe,damage,250,0,0
-baptiste,Baptiste,support,250,0,0
-bastion,Bastion,damage,250,100,0
-brigitte,Brigitte,support,200,50,0
-cassidy,Cassidy,damage,250,0,0
-dva,D.Va,tank,325,325,0
-domina,Domina,tank,250,0,400
-doomfist,Doomfist,tank,525,0,0
-echo,Echo,damage,150,0,75
-emre,Emre,damage,250,0,0
-freja,Freja,damage,225,0,0
-genji,Genji,damage,250,0,0
-hazard,Hazard,tank,425,225,0
-hanzo,Hanzo,damage,250,0,0
-illari,Illari,support,250,0,0
-jetpack-cat,Jetpack Cat,support,225,0,0
-junker-queen,Junker Queen,tank,525,0,0
-junkrat,Junkrat,damage,250,0,0
-juno,Juno,support,75,0,150
-kiriko,Kiriko,support,225,0,0
-lifeweaver,Lifeweaver,support,200,0,50
-lucio,Lúcio,support,225,0,0
-mauga,Mauga,tank,575,150,0
-mei,Mei,damage,300,0,0
-mercy,Mercy,support,225,0,0
-mizuki,Mizuki,support,250,0,0
-moira,Moira,support,225,0,0
-orisa,Orisa,tank,300,300,0
-pharah,Pharah,damage,225,0,0
-ramattra,Ramattra,tank,425,100,0
-reaper,Reaper,damage,300,0,0
-reinhardt,Reinhardt,tank,400,300,0
-roadhog,Roadhog,tank,750,0,0
-sigma,Sigma,tank,350,0,275
-sojourn,Sojourn,damage,225,0,0
-soldier-76,Soldier: 76,damage,250,0,0
-sombra,Sombra,damage,225,0,0
-symmetra,Symmetra,damage,125,0,150
-torbjorn,Torbjörn,damage,225,75,0
-tracer,Tracer,damage,175,0,0
-vendetta,Vendetta,damage,175,100,0
-venture,Venture,damage,250,0,0
-widowmaker,Widowmaker,damage,225,0,0
-winston,Winston,tank,425,200,0
-wrecking-ball,Wrecking Ball,tank,450,125,150
-wuyang,Wuyang,support,225,0,0
-zarya,Zarya,tank,325,0,225
-zenyatta,Zenyatta,support,75,0,175
diff --git a/app/heroes/enums.py b/app/heroes/enums.py
index 0a4cc602..92980c29 100644
--- a/app/heroes/enums.py
+++ b/app/heroes/enums.py
@@ -1,6 +1,6 @@
from enum import StrEnum
-from app.helpers import read_csv_data_file
+from app.adapters.csv import CSVReader
class BackgroundImageSize(StrEnum):
@@ -23,7 +23,7 @@ class MediaType(StrEnum):
# Dynamically create the HeroKey enum by using the CSV File
-heroes_data = read_csv_data_file("heroes")
+heroes_data = CSVReader.read_csv_file("heroes")
HeroKey = StrEnum(
"HeroKey",
{
diff --git a/app/heroes/parsers/__init__.py b/app/heroes/parsers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/heroes/parsers/hero_parser.py b/app/heroes/parsers/hero_parser.py
deleted file mode 100644
index 034bd135..00000000
--- a/app/heroes/parsers/hero_parser.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""Hero page Parser module"""
-
-import re
-from typing import TYPE_CHECKING, ClassVar
-
-from fastapi import status
-
-from app.config import settings
-from app.enums import Locale
-from app.exceptions import ParserBlizzardError
-from app.overfast_logger import logger
-from app.parsers import HTMLParser
-from app.roles.helpers import get_role_from_icon_url
-
-from ..enums import MediaType
-
-if TYPE_CHECKING:
- from selectolax.lexbor import LexborNode
-
-
-class HeroParser(HTMLParser):
- """Overwatch single hero page Parser class"""
-
- root_path = settings.heroes_path
- valid_http_codes: ClassVar[list] = [
- 200, # Classic response
- 404, # Hero Not Found response, we want to handle it here
- ]
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.locale = kwargs.get("locale") or Locale.ENGLISH_US
-
- def get_blizzard_url(self, **kwargs) -> str:
- return f"{super().get_blizzard_url(**kwargs)}{kwargs.get('hero_key')}/"
-
- async def parse_data(self) -> dict:
- # We must check if we have the expected section for hero. If not,
- # it means the hero hasn't been found and/or released yet.
- if not (
- abilities_section := self.root_tag.css_first("div.abilities-container")
- ):
- raise ParserBlizzardError(
- status_code=status.HTTP_404_NOT_FOUND,
- message="Hero not found or not released yet",
- )
-
- overview_section = self.root_tag.css_first("blz-page-header")
- lore_section = self.root_tag.css_first("blz-section.lore")
-
- return {
- **self.__get_summary(overview_section),
- "abilities": self.__get_abilities(abilities_section),
- "story": self.__get_story(lore_section),
- }
-
- def __get_summary(self, overview_section: LexborNode) -> dict:
- header_section = overview_section.css_first("blz-header")
- extra_list_items = overview_section.css_first("blz-list").css("blz-list-item")
- birthday, age = self._get_birthday_and_age(
- text=extra_list_items[2].css_first("p").text(), locale=self.locale
- )
- icon_url = extra_list_items[0].css_first("blz-icon").attributes["src"] or ""
-
- return {
- "name": header_section.css_first("h2").text(),
- "description": header_section.css_first("p").text().strip(),
- "role": get_role_from_icon_url(icon_url),
- "location": extra_list_items[1].text().strip(),
- "birthday": birthday,
- "age": age,
- }
-
- @staticmethod
- def _get_birthday_and_age(
- text: str, locale: Locale
- ) -> tuple[str | None, int | None]:
- """Get birthday and age from text for a given hero"""
-
- # Regex matching the birthday for every known locale
- birthday_regex = r"^(.*) [\((].*[::] ?(\d+).*[\))]$"
-
- result = re.match(birthday_regex, text)
- if not result:
- return None, None
-
- # Text corresponding to "Unknown" in the locale of the page
- unknown_texts = {
- Locale.GERMAN: "Unbekannt",
- Locale.ENGLISH_EU: "Unknown",
- Locale.ENGLISH_US: "Unknown",
- Locale.SPANISH_EU: "Desconocido",
- Locale.SPANISH_LATIN: "Desconocido",
- Locale.FRENCH: "Inconnu",
- Locale.ITALIANO: "Sconosciuto",
- Locale.JAPANESE: "不明",
- Locale.KOREAN: "알 수 없음",
- Locale.POLISH: "Nieznane",
- Locale.PORTUGUESE_BRAZIL: "Desconhecido",
- Locale.RUSSIAN: "Неизвестно",
- Locale.CHINESE_TAIWAN: "未知",
- }
- unknown_text = unknown_texts.get(locale, "Unknown")
-
- birthday = result[1] if result[1] != unknown_text else None
- age = int(result[2]) if result[2] else None
-
- return birthday, age
-
- @staticmethod
- def __get_abilities(abilities_section: LexborNode) -> list[dict]:
- carousel_section_div = abilities_section.css_first("blz-carousel-section")
- abilities_list_div = carousel_section_div.css_first("blz-carousel")
-
- abilities_desc = [
- (
- desc_div.css_first("p")
- .text()
- .strip()
- .replace("\r", "")
- .replace("\n", " ")
- )
- for desc_div in abilities_list_div.css("blz-feature")
- ]
-
- abilities_videos = [
- {
- "thumbnail": video_div.attributes["poster"],
- "link": {
- "mp4": video_div.attributes["mp4"],
- "webm": video_div.attributes["webm"],
- },
- }
- for video_div in carousel_section_div.css("blz-web-video")
- ]
-
- return [
- {
- "name": ability_div.attributes["label"],
- "description": abilities_desc[ability_index].strip(),
- "icon": ability_div.css_first("blz-image").attributes["src"],
- "video": abilities_videos[ability_index],
- }
- for ability_index, ability_div in enumerate(
- abilities_list_div.css_first("blz-tab-controls").css("blz-tab-control"),
- )
- ]
-
- def __get_story(self, lore_section: LexborNode) -> dict:
- showcase_section = lore_section.css_first("blz-showcase")
-
- return {
- "summary": (
- showcase_section.css_first("blz-header p")
- .text()
- .strip()
- .replace("\n", "")
- ),
- "media": self.__get_media(showcase_section),
- "chapters": self.__get_story_chapters(
- lore_section.css_first("blz-accordion-section blz-accordion")
- ),
- }
-
- def __get_media(self, showcase_section: LexborNode) -> dict | None:
- if video := showcase_section.css_first("blz-youtube-video"):
- return {
- "type": MediaType.VIDEO,
- "link": f"https://youtu.be/{video.attributes['youtube-id']}",
- }
-
- if button := showcase_section.css_first("blz-button"):
- if not button.attributes["href"]:
- logger.warning("Missing href attribute in button element")
- return None
-
- return {
- "type": (
- MediaType.SHORT_STORY
- if button.attributes["analytics-label"] == "short-story"
- else MediaType.COMIC
- ),
- "link": self._get_full_url(button.attributes["href"]),
- }
-
- return None
-
- @staticmethod
- def _get_full_url(url: str) -> str:
- """Get full URL from extracted URL. If URL begins with /, we use the
- blizzard host to get the full URL"""
- return f"{settings.blizzard_host}{url}" if url.startswith("/") else url
-
- @staticmethod
- def __get_story_chapters(accordion: LexborNode) -> list[dict]:
- chapters_content = [
- (
- " ".join(
- [paragraph.text() for paragraph in content_container.css("p,pr")],
- ).strip()
- )
- for content_container in accordion.css("div[slot=content]")
- ]
- chapters_picture = [
- picture.attributes["src"] for picture in accordion.css("blz-image")
- ]
- titles = [node for node in accordion.iter() if node.tag == "span"]
-
- return [
- {
- "title": title_span.text().capitalize().strip(),
- "content": chapters_content[title_index],
- "picture": chapters_picture[title_index],
- }
- for title_index, title_span in enumerate(titles)
- ]
diff --git a/app/heroes/parsers/hero_stats_summary_parser.py b/app/heroes/parsers/hero_stats_summary_parser.py
deleted file mode 100644
index a87009b1..00000000
--- a/app/heroes/parsers/hero_stats_summary_parser.py
+++ /dev/null
@@ -1,169 +0,0 @@
-"""Hero Stats Summary Parser module"""
-
-from typing import ClassVar
-
-from fastapi import status
-
-from app.config import settings
-from app.exceptions import ParserBlizzardError
-from app.parsers import JSONParser
-from app.players.enums import PlayerGamemode, PlayerPlatform
-
-
-class HeroStatsSummaryParser(JSONParser):
- """Hero Stats Summary Parser class"""
-
- request_headers_headers: ClassVar[dict] = JSONParser.request_headers | {
- "X-Requested-With": "XMLHttpRequest"
- }
-
- root_path: str = settings.hero_stats_path
-
- platform_mapping: ClassVar[dict[PlayerPlatform, str]] = {
- PlayerPlatform.PC: "PC",
- PlayerPlatform.CONSOLE: "Console",
- }
-
- gamemode_mapping: ClassVar[dict[PlayerGamemode, str]] = {
- PlayerGamemode.QUICKPLAY: "0",
- PlayerGamemode.COMPETITIVE: "1",
- }
-
- def __init__(self, **kwargs):
- # Mandatory query params
- self.platform_filter: str = self.platform_mapping[kwargs["platform"]]
- self.gamemode = kwargs["gamemode"]
- self.gamemode_filter: str = self.gamemode_mapping[self.gamemode]
- self.region_filter: str = kwargs["region"].capitalize()
- self.order_by: str = kwargs["order_by"]
-
- # Optional query params
- self.map_filter: str = kwargs.get("map") or "all-maps"
- self.role_filter: str | None = kwargs.get("role")
- self.competitive_division_filter: str = (
- kwargs.get("competitive_division") or "all"
- ).capitalize()
-
- super().__init__(**kwargs)
-
- async def parse_data(self) -> list[dict]:
- """
- Parses and filters hero data from the loaded JSON based on the current filters.
-
- Returns:
- list[dict]: A list of dictionaries containing hero stats data.
-
- Raises:
- ParserBlizzardError: If the selected map does not match the gamemode in the filters.
- """
- # Check if the data matches the expected map filter
- self.check_data_validity()
-
- # Role filter isn't applied server-side, so we filter it here if provided.
- hero_stats_data = self.filter_heroes()
-
- # Only retrieve needed data
- hero_stats_data = self.apply_transformations(hero_stats_data)
-
- # Apply ordering before returning
- return self.apply_ordering(hero_stats_data)
-
- def check_data_validity(self) -> None:
- """
- Validates that the selected map matches the current gamemode.
-
- Raises:
- ParserBlizzardError: If the selected map does not match the gamemode.
- """
- if self.map_filter != self.json_data["selected"]["map"]:
- raise ParserBlizzardError(
- status_code=status.HTTP_400_BAD_REQUEST,
- message=(
- f"Selected map '{self.map_filter}' is not compatible with '{self.gamemode}' gamemode."
- ),
- )
-
- def filter_heroes(self) -> list[dict]:
- """
- Filters hero statistics based on the current role filter.
-
- Returns:
- list[dict]: A list of hero entries matching the role filter (if provided).
- """
- return [
- rate
- for rate in self.json_data["rates"]
- if (
- self.role_filter is None
- or rate["hero"]["role"].lower() == self.role_filter
- )
- ]
-
- def apply_transformations(self, hero_stats_data: list[dict]) -> list[dict]:
- """
- Extracts and structures relevant hero statistics fields from the filtered data.
-
- Args:
- hero_stats_data (list[dict]): The filtered list of hero data.
-
- Returns:
- list[dict]: A list of dictionaries, each containing only the key statistics fields for a hero.
- """
- return [
- {
- "hero": rate["id"],
- "pickrate": self.get_rate_value(rate["cells"]["pickrate"]),
- "winrate": self.get_rate_value(rate["cells"]["winrate"]),
- }
- for rate in hero_stats_data
- ]
-
- def apply_ordering(self, hero_stats_data: list[dict]) -> list[dict]:
- """
- Orders the hero statistics data based on the specified field and direction.
-
- Returns:
- list[dict]: The input list, sorted according to the provided ordering.
- """
- order_field, order_arrangement = self.order_by.split(":")
- hero_stats_data.sort(
- key=lambda hero_stat: hero_stat[order_field],
- reverse=order_arrangement == "desc",
- )
- return hero_stats_data
-
- def get_blizzard_query_params(self, **kwargs) -> dict:
- """
- Constructs a dictionary of query parameters for a Blizzard API request based
- on the current filter attributes and additional keyword arguments.
-
- Args:
- **kwargs: Additional keyword arguments that may influence the query parameters.
- - gamemode (PlayerGamemode): The game mode being queried. If set to PlayerGamemode.COMPETITIVE,
- the 'tier' parameter will be included based on the competitive division filter.
-
- Returns:
- dict: A dictionary containing the Blizzard API query parameters, including platform, game mode,
- region, and map filters. If the game mode is competitive, the competitive division tier is also included.
- """
- blizzard_query_params = {
- "input": self.platform_filter,
- "rq": self.gamemode_filter,
- "region": self.region_filter,
- "map": self.map_filter,
- }
-
- if kwargs["gamemode"] == PlayerGamemode.COMPETITIVE:
- blizzard_query_params["tier"] = self.competitive_division_filter
-
- return blizzard_query_params
-
- @staticmethod
- def get_rate_value(rate: float) -> float:
- """
- Get the output rate value to display to users.
-
- Whenever data isn't available for some reason, data from Blizzard will be -1
- We're converting it to zero in order to be coherent regarding API return values.
- """
- return rate if rate != -1 else 0.0
diff --git a/app/heroes/parsers/heroes_parser.py b/app/heroes/parsers/heroes_parser.py
deleted file mode 100644
index b6c0ab42..00000000
--- a/app/heroes/parsers/heroes_parser.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""Heroes page Parser module"""
-
-from typing import cast
-
-from app.config import settings
-from app.exceptions import ParserParsingError
-from app.parsers import HTMLParser
-
-
-class HeroesParser(HTMLParser):
- """Overwatch heroes list page Parser class"""
-
- root_path = settings.heroes_path
-
- async def parse_data(self) -> list[dict]:
- heroes = []
- for hero in self.root_tag.css("div.heroIndexWrapper blz-media-gallery a"):
- if not hero.attributes["href"]:
- msg = "Invalid hero URL"
- raise ParserParsingError(msg)
- heroes.append(
- {
- "key": hero.attributes["href"].split("/")[-1],
- "name": hero.css_first("blz-card blz-content-block h2").text(),
- "portrait": hero.css_first("blz-card blz-image").attributes["src"],
- "role": hero.attributes["data-role"],
- }
- )
-
- return sorted(heroes, key=lambda hero: hero["key"])
-
- def filter_request_using_query(self, **kwargs) -> list[dict]:
- # Type assertion: we know parse_data returns list[dict]
- data = cast("list[dict]", self.data)
- role = kwargs.get("role")
- return (
- data
- if not role
- else [hero_dict for hero_dict in data if hero_dict["role"] == role]
- )
diff --git a/app/heroes/parsers/heroes_stats_parser.py b/app/heroes/parsers/heroes_stats_parser.py
deleted file mode 100644
index 4def48e0..00000000
--- a/app/heroes/parsers/heroes_stats_parser.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""Heroes Stats Parser module"""
-
-from typing import ClassVar
-
-from app.parsers import CSVParser
-
-
-class HeroesStatsParser(CSVParser):
- """Heroes stats (health, armor, shields) Parser class"""
-
- filename = "heroes"
- hitpoints_keys: ClassVar[set[str]] = {"health", "armor", "shields"}
-
- def parse_data(self) -> dict:
- return {
- hero_stats["key"]: {"hitpoints": self.__get_hitpoints(hero_stats)}
- for hero_stats in self.csv_data
- }
-
- def __get_hitpoints(self, hero_stats: dict) -> dict:
- hitpoints = {hp_key: int(hero_stats[hp_key]) for hp_key in self.hitpoints_keys}
- hitpoints["total"] = sum(hitpoints.values())
- return hitpoints
diff --git a/app/main.py b/app/main.py
index 40f8dde0..4bfaee24 100644
--- a/app/main.py
+++ b/app/main.py
@@ -13,6 +13,7 @@
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
+from .adapters.blizzard import OverFastClient
from .adapters.cache import CacheManager
from .adapters.storage import SQLiteStorage
from .api import gamemodes, heroes, maps, players, roles
@@ -27,7 +28,6 @@
TraceMallocMiddleware,
)
from .monitoring.middleware import register_prometheus_middleware
-from .overfast_client import OverFastClient
from .overfast_logger import logger
if TYPE_CHECKING:
diff --git a/app/maps/controllers/__init__.py b/app/maps/controllers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/maps/controllers/list_maps_controller.py b/app/maps/controllers/list_maps_controller.py
deleted file mode 100644
index fbccabf3..00000000
--- a/app/maps/controllers/list_maps_controller.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""List Maps Controller module"""
-
-from typing import ClassVar
-
-from app.adapters.blizzard.parsers.maps import parse_maps
-from app.config import settings
-from app.controllers import AbstractController
-
-
-class ListMapsController(AbstractController):
- """List Maps Controller used in order to retrieve a list of
- available Overwatch maps.
- """
-
- parser_classes: ClassVar[list[type]] = []
- timeout = settings.csv_cache_timeout
-
- async def process_request(self, **kwargs) -> list[dict]:
- """Process request using stateless parser function"""
- gamemode = kwargs.get("gamemode")
- data = parse_maps(gamemode=gamemode)
-
- # Dual-write to API Cache (Valkey) and Storage (SQLite)
- storage_key = "maps:en-us" # Maps are not localized
- await self.update_static_cache(data, storage_key, data_type="json")
-
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
diff --git a/app/maps/data/maps.csv b/app/maps/data/maps.csv
deleted file mode 100644
index 7fdb53ec..00000000
--- a/app/maps/data/maps.csv
+++ /dev/null
@@ -1,58 +0,0 @@
-key,name,gamemodes,location,country_code
-aatlis,Aatlis,flashpoint,Morocco,MA
-antarctic-peninsula,Antarctic Peninsula,control,Antarctica,AQ
-anubis,Temple of Anubis,assault,"Giza Plateau, Egypt",EG
-arena-victoriae,Arena Victoriae,control,"Colosseo, Rome, Italy",IT
-ayutthaya,Ayutthaya,capture-the-flag,Thailand,TH
-black-forest,Black Forest,elimination,Germany,DE
-blizzard-world,Blizzard World,hybrid,"Irvine, California, United States",US
-busan,Busan,control,South Korea,KR
-castillo,Castillo,elimination,Mexico,MX
-chateau-guillard,Château Guillard,"deathmatch,team-deathmatch","Annecy, France",FR
-circuit-royal,Circuit Royal,escort,"Monte Carlo, Monaco",MC
-colosseo,Colosseo,push,"Rome, Italy",IT
-dorado,Dorado,escort,Mexico,MX
-ecopoint-antarctica,Ecopoint: Antarctica,elimination,Antarctica,AQ
-eichenwalde,Eichenwalde,hybrid,"Stuttgart, Germany",DE
-esperanca,Esperança,push,Portugal,PT
-gogadoro,Gogadoro,control,"Busan, South Korea",KR
-hanamura,Hanamura,assault,"Tokyo, Japan",JP
-hanaoka,Hanaoka,clash,"Tokyo, Japan",JP
-havana,Havana,escort,"Havana, Cuba",CU
-hollywood,Hollywood,hybrid,"Los Angeles, United States",US
-horizon,Horizon Lunar Colony,assault,Earth's moon,
-ilios,Ilios,control,Greece,GR
-junkertown,Junkertown,escort,Central Australia,AU
-lijiang-tower,Lijiang Tower,control,China,CN
-kanezaka,Kanezaka,"deathmatch,team-deathmatch","Tokyo, Japan",JP
-kings-row,King’s Row,hybrid,"London, United Kingdom",UK
-malevento,Malevento,"deathmatch,team-deathmatch",Italy,IT
-midtown,Midtown,hybrid,"New York, United States",US
-necropolis,Necropolis,elimination,Egypt,EG
-nepal,Nepal,control,Nepal,NP
-new-junk-city,New Junk City,flashpoint,Central Australia,AU
-new-queen-street,New Queen Street,push,"Toronto, Canada",CA
-numbani,Numbani,hybrid,Numbani (near Nigeria),
-oasis,Oasis,control,Iraq,IQ
-paraiso,Paraíso,hybrid,"Rio de Janeiro, Brazil",BR
-paris,Paris,assault,"Paris, France",FR
-petra,Petra,"deathmatch,team-deathmatch",Southern Jordan,JO
-place-lacroix,Place Lacroix,push,"Paris, France",FR
-powder-keg-mine,Powder Keg Mine,payload-race,"Deadlock Gorge, Arizona, United States",US
-practice-range,Practice Range,practice-range,Swiss HQ,CH
-redwood-dam,Redwood Dam,push,Gibraltar,GI
-rialto,Rialto,escort,"Venice, Italy",IT
-route-66,Route 66,escort,"Albuquerque, New Mexico, United States",US
-runasapi,Runasapi,push,Peru,PE
-samoa,Samoa,control,Samoa,WS
-shambali-monastery,Shambali Monastery,escort,Nepal,NP
-suravasa,Suravasa,flashpoint,India,IN
-thames-district,Thames District,payload-race,"London, United Kingdom",UK
-throne-of-anubis,Throne of Anubis,clash,"Giza Plateau, Egypt",EG
-volskaya,Volskaya Industries,assault,"St. Petersburg, Russia",RU
-watchpoint-gibraltar,Watchpoint: Gibraltar,escort,Gibraltar,GI
-workshop-chamber,Workshop Chamber,workshop,Earth,
-workshop-expanse,Workshop Expanse,workshop,Earth,
-workshop-green-screen,Workshop Green Screen,workshop,Earth,
-workshop-island,Workshop Island,workshop,Earth,
-wuxing-university,Wuxing University,control,"Chengdu, Sichuan, China",CN
\ No newline at end of file
diff --git a/app/maps/enums.py b/app/maps/enums.py
index 10974755..bd37d5da 100644
--- a/app/maps/enums.py
+++ b/app/maps/enums.py
@@ -1,9 +1,9 @@
from enum import StrEnum
-from app.helpers import read_csv_data_file
+from app.adapters.csv import CSVReader
# Dynamically create the MapKey enum by using the CSV File
-maps_data = read_csv_data_file("maps")
+maps_data = CSVReader.read_csv_file("maps")
MapKey = StrEnum(
"MapKey",
{
diff --git a/app/maps/parsers/__init__.py b/app/maps/parsers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/maps/parsers/maps_parser.py b/app/maps/parsers/maps_parser.py
deleted file mode 100644
index 6a776a8b..00000000
--- a/app/maps/parsers/maps_parser.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Maps Parser module"""
-
-from typing import cast
-
-from app.parsers import CSVParser
-
-
-class MapsParser(CSVParser):
- """Overwatch maps list page Parser class"""
-
- filename = "maps"
-
- def parse_data(self) -> list[dict]:
- return [
- {
- "key": map_dict["key"],
- "name": map_dict["name"],
- "screenshot": self.get_static_url(map_dict["key"]),
- "gamemodes": map_dict["gamemodes"].split(","),
- "location": map_dict["location"],
- "country_code": map_dict.get("country_code") or None,
- }
- for map_dict in self.csv_data
- ]
-
- def filter_request_using_query(self, **kwargs) -> list:
- # Type assertion: we know parse_data returns list[dict]
- data = cast("list[dict]", self.data)
- gamemode = kwargs.get("gamemode")
- return (
- data
- if not gamemode
- else [map_dict for map_dict in data if gamemode in map_dict["gamemodes"]]
- )
diff --git a/app/monitoring/metrics.py b/app/monitoring/metrics.py
index 164fb6b5..1a9b3cb9 100644
--- a/app/monitoring/metrics.py
+++ b/app/monitoring/metrics.py
@@ -66,6 +66,25 @@
"Stale responses served from persistent storage (SWR)",
)
+# Background refresh tasks triggered (Phase 4 onwards)
+background_refresh_triggered_total = Counter(
+ "background_refresh_triggered_total",
+ "Background refresh tasks triggered by SWR staleness detection",
+ ["entity_type"], # "heroes", "maps", "gamemodes", "roles", "player", "hero_stats"
+)
+
+background_refresh_completed_total = Counter(
+ "background_refresh_completed_total",
+ "Background refresh tasks that completed successfully",
+ ["entity_type"],
+)
+
+background_refresh_failed_total = Counter(
+ "background_refresh_failed_total",
+ "Background refresh tasks that raised an exception",
+ ["entity_type"],
+)
+
########################
# Background Task Metrics (Phase 5)
########################
diff --git a/app/overfast_client.py b/app/overfast_client.py
deleted file mode 100644
index f6a1784f..00000000
--- a/app/overfast_client.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""
-OverFast HTTP client - DEPRECATED, moved to app/adapters/blizzard/client.py
-
-This module is kept for backward compatibility during the migration to v4.
-Import from app.adapters.blizzard instead.
-"""
-
-from app.adapters.blizzard import OverFastClient
-
-__all__ = ["OverFastClient"]
diff --git a/app/parsers.py b/app/parsers.py
deleted file mode 100644
index e03c252b..00000000
--- a/app/parsers.py
+++ /dev/null
@@ -1,168 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, ClassVar
-
-from fastapi import status
-from selectolax.lexbor import LexborHTMLParser
-
-from .cache_manager import CacheManager
-from .config import settings
-from .enums import Locale
-from .exceptions import ParserParsingError
-from .helpers import read_csv_data_file
-from .overfast_client import OverFastClient
-from .overfast_logger import logger
-
-if TYPE_CHECKING:
- import httpx
-
-
-class AbstractParser(ABC):
- """Abstract Parser class used to define generic behavior for parsers.
-
- A parser is meant to convert some input data into meaningful data
- in dict/list format. The Parse Cache system is handled here.
- """
-
- cache_manager = CacheManager()
-
- def __init__(self, **_):
- self.data: dict | list | None = None
-
- @abstractmethod
- async def parse(self) -> None:
- """Method used to retrieve data, parsing it and
- storing it into self.data attribute.
- """
-
- def filter_request_using_query(self, **_) -> dict | list | None:
- """If the route contains subroutes accessible using GET queries, this method
- will filter data using the query data. This method should be
- redefined in child classes if needed. The default behaviour is to return
- the parsed data directly.
- """
- return self.data
-
-
-class CSVParser(AbstractParser):
- """CSV Parser class used to define generic behavior for parsers used
- to extract data from local CSV files.
- """
-
- # Name of CSV file to retrieve (without extension), also
- # used as a sub-folder name for storing related static files
- filename: str
-
- async def parse(self) -> None:
- """Method used to retrieve data from CSV file and storing
- it into self.data attribute
- """
-
- # Read the CSV file
- self.csv_data = read_csv_data_file(self.filename)
-
- # Parse the data
- self.data = self.parse_data()
-
- @abstractmethod
- def parse_data(self) -> dict | list[dict]:
- """Main submethod of the parser, mainly doing the parsing of CSV data and
- returning a dict, which will be cached and used by the API. Can
- raise an error if there is an issue when parsing the data.
- """
-
- def get_static_url(self, key: str, extension: str = "jpg") -> str:
- """Method used to retrieve the URL of a local static file"""
- return f"{settings.app_base_url}/static/{self.filename}/{key}.{extension}"
-
-
-class APIParser(AbstractParser):
- """Abstract API Parser class used to define generic behavior for parsers used
- to extract data from Blizzard HTML pages. The blizzard URL call is handled here.
- """
-
- # List of valid HTTP codes when retrieving Blizzard pages
- valid_http_codes: ClassVar[list] = [status.HTTP_200_OK]
-
- # Request headers to send while making the request
- request_headers: ClassVar[dict] = {}
-
- def __init__(self, **kwargs):
- self.blizzard_url = self.get_blizzard_url(**kwargs)
- self.blizzard_query_params = self.get_blizzard_query_params(**kwargs)
- self.overfast_client = OverFastClient()
- super().__init__(**kwargs)
-
- @property
- @abstractmethod
- def root_path(self) -> str:
- """Root path of the Blizzard URL containing the data (/en-us/career/, etc."""
-
- @abstractmethod
- def store_response_data(self, response: httpx.Response) -> None:
- """Submethod to handle response data storage"""
-
- @abstractmethod
- async def parse_data(self) -> dict | list[dict]:
- """Main submethod of the parser, mainly doing the parsing of input data and
- returning a dict, which will be cached and used by the API. Can
- raise an error if there is an issue when parsing the data.
- """
-
- async def parse(self) -> None:
- """Method used to retrieve data from Blizzard, parsing it
- and storing it into self.data attribute.
- """
- response = await self.overfast_client.get(
- url=self.blizzard_url,
- headers=self.request_headers,
- params=self.blizzard_query_params,
- )
- if response.status_code not in self.valid_http_codes:
- raise self.overfast_client.blizzard_response_error_from_response(response)
-
- # Store associated request data
- self.store_response_data(response)
-
- # Parse stored request data
- await self.parse_response_data()
-
- def get_blizzard_url(self, **kwargs) -> str:
- """URL used when requesting data to Blizzard. It usually is a concatenation
- of root url and query data (kwargs) if the Controller supports it.
- For example : single hero page (hero key), player career page
- (player id, etc.). Default is just the blizzard root url.
- """
- locale = kwargs.get("locale") or Locale.ENGLISH_US
- return f"{settings.blizzard_host}/{locale}{self.root_path}"
-
- def get_blizzard_query_params(self, **_) -> dict:
- """Query params to use when calling Blizzard URL. Defaults to empty dict"""
- return {}
-
- async def parse_response_data(self) -> None:
- logger.info("Parsing data...")
- try:
- self.data = await self.parse_data()
- except (AttributeError, KeyError, IndexError, TypeError) as error:
- raise ParserParsingError(repr(error)) from error
-
-
-class HTMLParser(APIParser):
- request_headers: ClassVar[dict] = {"Accept": "text/html"}
-
- def store_response_data(self, response: httpx.Response) -> None:
- """Initialize parser tag with Blizzard response"""
- self.create_parser_tag(response.text)
-
- def create_parser_tag(self, html_content: str) -> None:
- self.root_tag = LexborHTMLParser(html_content).css_first(
- "div.main-content,main"
- )
-
-
-class JSONParser(APIParser):
- request_headers: ClassVar[dict] = {"Accept": "application/json"}
-
- def store_response_data(self, response: httpx.Response) -> None:
- """Initialize object with Blizzard response"""
- self.json_data = response.json()
diff --git a/app/players/controllers/__init__.py b/app/players/controllers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/players/controllers/base_player_controller.py b/app/players/controllers/base_player_controller.py
deleted file mode 100644
index 2316279d..00000000
--- a/app/players/controllers/base_player_controller.py
+++ /dev/null
@@ -1,415 +0,0 @@
-"""Base Player Controller module"""
-
-import time
-from typing import TYPE_CHECKING
-
-from fastapi import HTTPException, status
-
-from app.adapters.blizzard.parsers.player_profile import (
- extract_name_from_profile_html,
- fetch_player_html,
-)
-from app.adapters.blizzard.parsers.player_summary import (
- fetch_player_summary_json,
- parse_player_summary_json,
-)
-from app.adapters.blizzard.parsers.utils import is_blizzard_id
-from app.config import settings
-from app.controllers import AbstractController
-from app.exceptions import ParserBlizzardError, ParserParsingError
-from app.helpers import overfast_internal_error
-from app.monitoring.metrics import (
- sqlite_battletag_lookup_total,
- sqlite_cache_hit_total,
- storage_hits_total,
-)
-from app.overfast_logger import logger
-
-if TYPE_CHECKING:
- from app.domain.ports import BlizzardClientPort
-
-
-class BasePlayerController(AbstractController):
- """Base Player Controller used in order to ensure specific exceptions
- are properly handled. For instance, not found players should be cached
- to prevent spamming Blizzard with calls.
- """
-
- # Player Profile Cache Methods (SQLite-based, replacing Valkey Player Cache)
-
- async def get_player_profile_cache(
- self, player_id: str
- ) -> dict[str, str | dict | None] | None:
- """
- Get player profile from persistent storage.
- Returns dict compatible with old Valkey cache format: {"summary": {...}, "profile": "..."}
-
- Phase 3.5B: player_id is now always Blizzard ID.
-
- Args:
- player_id: Blizzard ID (canonical key)
-
- Returns:
- Dict with 'summary', 'profile', 'battletag', 'name' keys, or None if not found
- """
- profile = await self.storage.get_player_profile(player_id)
- if not profile:
- # Track cache miss
- if settings.prometheus_enabled:
- sqlite_cache_hit_total.labels(
- table="player_profiles", result="miss"
- ).inc()
- storage_hits_total.labels(result="miss").inc()
- return None
-
- # Track cache hit
- if settings.prometheus_enabled:
- sqlite_cache_hit_total.labels(table="player_profiles", result="hit").inc()
- storage_hits_total.labels(result="hit").inc()
-
- # Storage now returns full summary from summary_json column
- return {
- "profile": profile["html"],
- "summary": profile["summary"], # Full summary with all fields
- "battletag": profile.get("battletag"), # Optional metadata
- "name": profile.get("name"), # Display name
- }
-
- async def update_player_profile_cache(
- self,
- player_id: str,
- player_summary: dict,
- html: str,
- battletag: str | None = None,
- name: str | None = None,
- ) -> None:
- """
- Update player profile in persistent storage.
-
- Phase 3.5B: Uses Blizzard ID as key, stores battletag/name as optional metadata.
-
- Args:
- player_id: Blizzard ID (canonical key)
- player_summary: Full summary from search endpoint (all fields), may be empty dict
- html: Raw career page HTML
- battletag: Full BattleTag from user input (e.g., "TeKrop-2217"), optional
- name: Display name from HTML or summary (e.g., "TeKrop"), optional
- """
- await self.storage.set_player_profile(
- player_id=player_id,
- html=html,
- summary=player_summary if player_summary else None, # None if empty dict
- battletag=battletag,
- name=name,
- )
-
- # Unknown Player Tracking Methods (Valkey-based with exponential backoff)
- # Early rejection is handled at the nginx/Lua layer via the cooldown key.
- # Python only handles the write path: marking players as unknown on 404
- # and deleting their status when they become known.
-
- def _calculate_retry_after(self, check_count: int) -> int:
- """
- Calculate retry_after time using exponential backoff.
-
- Formula: min(base * multiplier^(check_count - 1), max)
- Example: 600 * 3^0 = 600s (10min), 600 * 3^1 = 1800s (30min), etc.
-
- Args:
- check_count: Number of failed checks
-
- Returns:
- Seconds to wait before next check (capped at max)
- """
- base = settings.unknown_player_initial_retry
- multiplier = settings.unknown_player_retry_multiplier
- max_retry = settings.unknown_player_max_retry
-
- retry_after = base * (multiplier ** (check_count - 1))
- return min(int(retry_after), max_retry)
-
- async def mark_player_unknown_on_404(
- self,
- blizzard_id: str,
- exception: HTTPException,
- battletag: str | None = None,
- ) -> None:
- """
- Mark player as unknown if 404 exception is raised.
- Implements exponential backoff for retries.
-
- Stores by Blizzard ID (primary) with optional BattleTag cooldown key
- for early rejection before identity resolution.
-
- Updates the exception detail to match PlayerNotFoundError format
- with retry_after, next_check_at, and check_count fields.
-
- Args:
- blizzard_id: Resolved Blizzard ID (always available after redirect)
- exception: HTTPException to modify and check for 404 status
- battletag: Optional BattleTag from user request (enables early check)
- """
- if not settings.unknown_players_cache_enabled:
- return
-
- if exception.status_code != status.HTTP_404_NOT_FOUND:
- return
-
- # Get existing status to increment check count (works even if cooldown expired)
- player_status = await self.cache_manager.get_player_status(blizzard_id)
- check_count = player_status["check_count"] + 1 if player_status else 1
-
- # Calculate exponential backoff and next check time
- retry_after = self._calculate_retry_after(check_count)
- next_check_at = int(time.time()) + retry_after
-
- await self.cache_manager.set_player_status(
- blizzard_id, check_count, retry_after, battletag=battletag
- )
-
- # Update exception detail to match PlayerNotFoundError model
- exception.detail = {
- "error": "Player not found",
- "retry_after": retry_after,
- "next_check_at": next_check_at,
- "check_count": check_count,
- }
-
- logger.info(
- f"Marked player {blizzard_id} as unknown"
- f"{f' (battletag: {battletag})' if battletag else ''} "
- f"(check #{check_count}, retry in {retry_after}s, next check at {next_check_at})"
- )
-
- async def _enrich_summary_from_blizzard_id(
- self, client: BlizzardClientPort, blizzard_id: str
- ) -> tuple[dict, str | None]:
- """Reverse enrichment: fetch HTML, extract name, search for matching summary.
-
- When user provides a Blizzard ID directly, we can still enrich the response
- with player summary by extracting the name from HTML and searching.
-
- Args:
- client: Blizzard API client
- blizzard_id: Blizzard ID to enrich
-
- Returns:
- Tuple of (player_summary, html):
- - player_summary: Enriched summary dict if found, empty dict otherwise
- - html: Profile HTML (always returned if fetch succeeds)
- """
- # Fetch HTML to extract player name
- html, _ = await fetch_player_html(client, blizzard_id)
-
- if not html:
- logger.warning("Could not fetch HTML for Blizzard ID, no enrichment")
- return {}, None
-
- # Extract name from HTML (e.g., "TeKrop" from profile)
- try:
- player_name = extract_name_from_profile_html(html)
-
- if player_name:
- logger.info(
- f"Extracted name '{player_name}', searching for matching summary"
- )
- # Search with the name to find summary
- search_json = await fetch_player_summary_json(client, player_name)
- # Find the entry that matches our Blizzard ID
- player_summary = parse_player_summary_json(
- search_json, player_name, blizzard_id
- )
-
- if player_summary:
- logger.info("Successfully enriched summary from name-based search")
- return player_summary, html
-
- logger.warning(
- f"Name '{player_name}' found in HTML but no matching summary in search"
- )
- else:
- logger.warning("Could not extract name from HTML for enrichment")
- except Exception as e: # noqa: BLE001
- logger.warning(f"Error during reverse enrichment: {e}")
-
- # Enrichment failed but we have HTML - return what we have
- return {}, html
-
- async def _resolve_player_identity(
- self, client: BlizzardClientPort, player_id: str
- ) -> tuple[str | None, dict, str | None, str | None]:
- """Resolve player identity via search and Blizzard ID redirect if needed.
-
- This method implements the identity resolution flow:
- 1. If Blizzard ID: Attempt reverse enrichment (fetch HTML → extract name → search)
- 2. If BattleTag: Fetch and parse search results
- 3. If not found in search: Check SQLite for cached BattleTag→Blizzard ID mapping
- 4. If still not found: Attempt Blizzard ID resolution via redirect (returns HTML)
- 5. Re-parse search results with Blizzard ID if obtained
-
- Phase 3.5B optimizations:
- - SQLite lookup before redirect to avoid redundant Blizzard calls
- - Reverse enrichment: When Blizzard ID provided, extract name and search for summary
- This builds summary cache even when users provide direct Blizzard IDs
-
- Args:
- client: Blizzard API client
- player_id: Player identifier (BattleTag or Blizzard ID)
-
- Returns:
- Tuple of (blizzard_id, player_summary, profile_html, battletag_input):
- - blizzard_id: Canonical Blizzard ID (cache key), None if not resolved yet
- - player_summary: dict from search (may be empty if direct Blizzard ID or not found)
- - profile_html: HTML from profile page if fetched during resolution, None otherwise
- - battletag_input: Original BattleTag from user input if applicable, None for Blizzard ID input
- """
- logger.info("Retrieving Player Summary...")
-
- # Blizzard ID provided - attempt reverse enrichment
- if is_blizzard_id(player_id):
- logger.info(
- "Player ID is a Blizzard ID, attempting reverse enrichment from name"
- )
- player_summary, html = await self._enrich_summary_from_blizzard_id(
- client, player_id
- )
- return player_id, player_summary, html, None
-
- # User provided BattleTag - track it for storage
- battletag_input = player_id
-
- # Fetch and parse search results
- search_json = await fetch_player_summary_json(client, player_id)
- player_summary = parse_player_summary_json(search_json, player_id)
-
- if player_summary:
- logger.info("Player Summary retrieved!")
- blizzard_id = player_summary.get("url")
- return blizzard_id, player_summary, None, battletag_input
-
- # Player not found in search - check SQLite cache before attempting redirect
- logger.info(
- "Player not found in search, checking SQLite for cached Blizzard ID mapping"
- )
- cached_blizzard_id = await self.storage.get_player_id_by_battletag(
- battletag_input
- )
-
- if cached_blizzard_id:
- # Track BattleTag lookup hit
- if settings.prometheus_enabled:
- sqlite_battletag_lookup_total.labels(result="hit").inc()
-
- logger.info(
- f"Found cached Blizzard ID {cached_blizzard_id} for {battletag_input}, "
- "skipping redirect"
- )
- # Re-parse search with cached Blizzard ID (handles multiple matches)
- player_summary = parse_player_summary_json(
- search_json, player_id, cached_blizzard_id
- )
- if player_summary:
- logger.info("Successfully resolved player using cached Blizzard ID")
- # No HTML since we skipped the redirect
- return cached_blizzard_id, player_summary, None, battletag_input
-
- logger.warning(
- "Cached Blizzard ID found but couldn't resolve player in search results"
- )
- # Track BattleTag lookup miss
- elif settings.prometheus_enabled:
- sqlite_battletag_lookup_total.labels(result="miss").inc()
-
- # Last resort: attempt Blizzard ID resolution via redirect
- logger.info("No cached mapping found, attempting Blizzard ID resolution")
- # Step 1: Capture HTML from this call to avoid double-fetch
- html, blizzard_id = await fetch_player_html(client, player_id)
-
- if blizzard_id and search_json:
- logger.info(
- f"Got Blizzard ID from redirect: {blizzard_id}, re-parsing search results"
- )
- player_summary = parse_player_summary_json(
- search_json, player_id, blizzard_id
- )
-
- if player_summary:
- logger.info("Successfully resolved player via Blizzard ID")
- # Return the HTML we already fetched
- return blizzard_id, player_summary, html, battletag_input
-
- logger.warning(
- "Could not resolve player even with Blizzard ID from redirect"
- )
-
- # Always return HTML if we fetched it, even if blizzard_id or parsing failed
- # This avoids a second fetch in the controller
- return blizzard_id, {}, html, battletag_input
-
- async def handle_player_request_exceptions(
- self,
- error: Exception,
- cache_key: str,
- battletag_input: str | None,
- player_summary: dict,
- ):
- """
- Shared exception handler for player controllers to eliminate code duplication.
-
- Handles ParserBlizzardError, ParserParsingError, and HTTPException uniformly
- across all player endpoints. Marks players as unknown on 404 errors.
-
- This method always raises - it processes the error and then re-raises it.
-
- Args:
- error: The exception that was caught
- cache_key: The cache key (usually Blizzard ID or player_id)
- battletag_input: BattleTag from user input (optional)
- player_summary: Player summary dict (may be empty)
-
- Raises:
- HTTPException: Always raises after processing the error
- """
- if isinstance(error, ParserBlizzardError):
- exception = HTTPException(
- status_code=error.status_code,
- detail=error.message,
- )
- # Mark unknown on 404 from Blizzard
- if error.status_code == status.HTTP_404_NOT_FOUND:
- await self.mark_player_unknown_on_404(
- cache_key, exception, battletag=battletag_input
- )
- raise exception from error
-
- if isinstance(error, ParserParsingError):
- # Check if error message indicates player not found
- # This can happen when HTML structure is malformed or missing expected elements
- if "Could not find main content in HTML" in str(error):
- exception = HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Player not found",
- )
- # Mark unknown with Blizzard ID (primary) and BattleTag (if available)
- await self.mark_player_unknown_on_404(
- cache_key, exception, battletag=battletag_input
- )
- raise exception from error
-
- # Get Blizzard URL for error reporting
- blizzard_url = (
- f"{settings.blizzard_host}{settings.career_path}/"
- f"{player_summary.get('url', cache_key) if player_summary else cache_key}/"
- )
- raise overfast_internal_error(blizzard_url, error) from error
-
- if isinstance(error, HTTPException):
- # Mark unknown on any 404 (explicit HTTPException)
- if error.status_code == status.HTTP_404_NOT_FOUND:
- await self.mark_player_unknown_on_404(
- cache_key, error, battletag=battletag_input
- )
- raise error
-
- # Unknown exception type - re-raise as-is
- raise error
diff --git a/app/players/controllers/get_player_career_controller.py b/app/players/controllers/get_player_career_controller.py
deleted file mode 100644
index d918f0cf..00000000
--- a/app/players/controllers/get_player_career_controller.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""Player Career Controller module"""
-
-from typing import TYPE_CHECKING, ClassVar, cast
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.player_profile import (
- extract_name_from_profile_html,
- fetch_player_html,
- filter_all_stats_data,
- filter_stats_by_query,
- parse_player_profile_html,
-)
-from app.config import settings
-from app.overfast_logger import logger
-
-if TYPE_CHECKING:
- from app.domain.ports import BlizzardClientPort
- from app.players.enums import PlayerGamemode, PlayerPlatform
-
-from .base_player_controller import BasePlayerController
-
-
-class GetPlayerCareerController(BasePlayerController):
- """Player Career Controller used in order to retrieve data about a player
- Overwatch career : summary, statistics about heroes, etc.
- """
-
- parser_classes: ClassVar[list] = []
- timeout = settings.career_path_cache_timeout
-
- async def process_request(self, **kwargs) -> dict:
- """Process request with Player Cache support and unknown player guard"""
- player_id = kwargs["player_id"]
- client = BlizzardClient()
-
- # Initialize variables for exception handling (must be in scope for except block)
- cache_key = player_id
- battletag_input = None
- player_summary = {}
-
- try:
- # Step 1: Resolve player identity (search + Blizzard ID resolution)
- # Phase 3.5B: Returns (blizzard_id, summary, cached_html, battletag_input)
- (
- blizzard_id,
- player_summary,
- cached_html,
- battletag_input,
- ) = await self._resolve_player_identity(client, player_id)
-
- # Use Blizzard ID as canonical key (fallback to player_id if not resolved yet)
- cache_key = blizzard_id or player_id
-
- # Step 2: Fetch profile with cache optimization
- # Pass cached_html to avoid redundant Blizzard call
- _html, profile_data = await self._fetch_profile_with_cache(
- client,
- cache_key,
- player_summary,
- battletag_input,
- cached_html=cached_html,
- )
-
- # Step 3: Apply filters
- data = self._filter_profile_data(
- profile_data,
- bool(kwargs.get("summary")),
- bool(kwargs.get("stats")),
- kwargs.get("platform"),
- kwargs.get("gamemode"),
- kwargs.get("hero"),
- )
-
- except Exception as error: # noqa: BLE001
- # Use shared exception handler (always raises)
- await self.handle_player_request_exceptions(
- error, cache_key, battletag_input, player_summary
- )
-
- # Update API Cache
- await self.cache_manager.update_api_cache(self.cache_key, data, self.timeout)
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
-
- async def _fetch_profile_with_cache(
- self,
- client: BlizzardClientPort,
- blizzard_id: str,
- player_summary: dict,
- battletag_input: str | None,
- cached_html: str | None = None,
- ) -> tuple[str, dict]:
- """Fetch player profile HTML with cache optimization.
-
- Phase 3.5B: Uses Blizzard ID as cache key, extracts name from HTML,
- stores battletag as optional metadata.
-
- Args:
- client: Blizzard API client
- blizzard_id: Blizzard ID (canonical cache key)
- player_summary: Player summary from search (may be empty for direct Blizzard ID requests)
- battletag_input: BattleTag from user input (None if user provided Blizzard ID)
- cached_html: HTML already fetched during identity resolution (Step 1 optimization)
-
- Returns:
- Tuple of (html, profile_data)
- """
- # Step 1 optimization: Use cached HTML from _resolve_player_identity
- if cached_html:
- logger.info(
- "Using cached HTML from identity resolution (avoiding double-fetch)"
- )
- profile_data = parse_player_profile_html(cached_html, player_summary)
-
- # Extract name and store profile
- name = extract_name_from_profile_html(cached_html) or player_summary.get(
- "name"
- )
- await self.update_player_profile_cache(
- blizzard_id, player_summary, cached_html, battletag_input, name
- )
-
- return cached_html, profile_data
-
- # Check Player Cache (SQLite storage) using Blizzard ID as key
- logger.info("Checking Player Cache...")
- player_cache = await self.get_player_profile_cache(blizzard_id)
-
- if (
- player_cache is not None
- and player_summary # Have summary to compare
- and player_cache["summary"]["lastUpdated"] # ty: ignore[invalid-argument-type, not-subscriptable]
- == player_summary["lastUpdated"]
- ):
- logger.info("Player Cache found and up-to-date, using it")
- html = cast("str", player_cache["profile"])
- profile_data = parse_player_profile_html(html, player_summary)
-
- # Update battletag if provided (progressive enhancement)
- if battletag_input and not player_cache.get("battletag"):
- logger.info(f"Enriching cache with BattleTag: {battletag_input}")
- cached_name = player_cache.get("name")
- name_str = cached_name if isinstance(cached_name, str) else None
- await self.update_player_profile_cache(
- blizzard_id, player_summary, html, battletag_input, name_str
- )
-
- return html, profile_data
-
- # Fetch from Blizzard with Blizzard ID
- logger.info("Player Cache not found or not up-to-date, calling Blizzard")
- html, _ = await fetch_player_html(client, blizzard_id)
- profile_data = parse_player_profile_html(html, player_summary)
-
- # Extract name and Update Player Cache (SQLite storage)
- name = extract_name_from_profile_html(html) or player_summary.get("name")
- await self.update_player_profile_cache(
- blizzard_id, player_summary, html, battletag_input, name
- )
-
- return html, profile_data
-
- async def _fetch_player_html(
- self, client: BlizzardClientPort, player_id: str
- ) -> tuple[str, str | None]:
- """Fetch player HTML from Blizzard and extract Blizzard ID from redirect"""
- return await fetch_player_html(client, player_id)
-
- def _filter_profile_data(
- self,
- profile_data: dict,
- summary_filter: bool,
- stats_filter: bool,
- platform_filter: PlayerPlatform | None,
- gamemode_filter: PlayerGamemode | None,
- hero_filter: str | None,
- ) -> dict:
- """Apply query filters to profile data"""
- # If only summary requested
- if summary_filter:
- return profile_data.get("summary") or {}
-
- # If only stats requested
- if stats_filter:
- return filter_stats_by_query(
- profile_data.get("stats") or {},
- platform_filter,
- gamemode_filter,
- hero_filter,
- )
-
- # Both summary and stats (with optional platform/gamemode filters)
- return {
- "summary": profile_data.get("summary") or {},
- "stats": filter_all_stats_data(
- profile_data.get("stats") or {},
- platform_filter,
- gamemode_filter,
- ),
- }
diff --git a/app/players/controllers/get_player_career_stats_controller.py b/app/players/controllers/get_player_career_stats_controller.py
deleted file mode 100644
index b7ffb8ed..00000000
--- a/app/players/controllers/get_player_career_stats_controller.py
+++ /dev/null
@@ -1,170 +0,0 @@
-"""Player Career Stats Controller module"""
-
-from typing import TYPE_CHECKING, ClassVar, cast
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.player_career_stats import (
- parse_player_career_stats_from_html,
-)
-from app.adapters.blizzard.parsers.player_profile import (
- extract_name_from_profile_html,
- fetch_player_html,
-)
-from app.config import settings
-from app.exceptions import ParserParsingError
-from app.overfast_logger import logger
-
-if TYPE_CHECKING:
- from app.domain.ports import BlizzardClientPort
- from app.players.enums import PlayerGamemode, PlayerPlatform
-
-from .base_player_controller import BasePlayerController
-
-
-class GetPlayerCareerStatsController(BasePlayerController):
- """Player Career Stats Controller used in order to retrieve career
- statistics of a player without labels, easily explorable
- """
-
- parser_classes: ClassVar[list] = []
- timeout = settings.career_path_cache_timeout
-
- async def process_request(self, **kwargs) -> dict:
- """Process request with Player Cache support and unknown player guard"""
- player_id = kwargs["player_id"]
- platform = kwargs.get("platform")
- gamemode = kwargs.get("gamemode")
- hero = kwargs.get("hero")
- client = BlizzardClient()
-
- # Initialize variables for exception handling (must be in scope for except block)
- cache_key = player_id
- battletag_input = None
- player_summary = {}
-
- try:
- # Step 1: Resolve player identity (search + Blizzard ID resolution)
- # Returns 4-tuple: (blizzard_id, summary, html, battletag_input)
- (
- blizzard_id,
- player_summary,
- cached_html,
- battletag_input,
- ) = await self._resolve_player_identity(client, player_id)
-
- # Use Blizzard ID as canonical key (fallback to player_id if not resolved yet)
- cache_key = blizzard_id or player_id
-
- # Step 2: Fetch career stats with cache optimization
- # Pass cached_html to avoid redundant Blizzard call
- data = await self._fetch_career_stats_with_cache(
- client,
- cache_key,
- player_summary,
- platform,
- gamemode,
- hero,
- battletag_input=battletag_input,
- cached_html=cached_html,
- )
-
- except Exception as error: # noqa: BLE001
- # Use shared exception handler (always raises)
- await self.handle_player_request_exceptions(
- error, cache_key, battletag_input, player_summary
- )
-
- # Update API Cache
- await self.cache_manager.update_api_cache(self.cache_key, data, self.timeout)
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
-
- async def _fetch_career_stats_with_cache(
- self,
- client: BlizzardClientPort,
- blizzard_id: str | None,
- player_summary: dict,
- platform: PlayerPlatform | None,
- gamemode: PlayerGamemode | None,
- hero: str | None,
- battletag_input: str | None = None,
- cached_html: str | None = None,
- ) -> dict:
- """Fetch player career stats with cache optimization.
-
- Uses Blizzard ID as the canonical identifier for all caching operations.
- BattleTag is stored as optional metadata but never used as a cache key.
-
- Args:
- client: Blizzard API client
- blizzard_id: Player's Blizzard ID (canonical identifier)
- player_summary: Player summary from search
- platform: Optional platform filter
- gamemode: Optional gamemode filter
- hero: Optional hero filter
- battletag_input: BattleTag from user's request URL (optional metadata)
- cached_html: HTML already fetched during identity resolution
-
- Returns:
- Player career stats data
- """
- # Step 1 optimization: Use cached HTML from _resolve_player_identity
- if cached_html:
- logger.info(
- "Using cached HTML from identity resolution (avoiding double-fetch)"
- )
- return parse_player_career_stats_from_html(
- cached_html, player_summary, platform, gamemode, hero
- )
-
- # No Blizzard ID - should not happen but handle defensively
- if not blizzard_id:
- msg = "Unable to resolve player identity"
- logger.warning("No Blizzard ID available, cannot fetch career stats")
- raise ParserParsingError(msg)
-
- # Check Player Cache (SQLite storage) - keyed by Blizzard ID
- logger.info(f"Checking Player Cache for Blizzard ID: {blizzard_id}")
- player_cache = await self.get_player_profile_cache(blizzard_id)
-
- if (
- player_cache is not None
- and player_summary
- and player_cache["summary"]["lastUpdated"] # ty: ignore[invalid-argument-type, not-subscriptable]
- == player_summary["lastUpdated"]
- ):
- logger.info("Player Cache found and up-to-date, using it")
- html = cast("str", player_cache["profile"])
- return parse_player_career_stats_from_html(
- html, player_summary, platform, gamemode, hero
- )
-
- # Fetch from Blizzard using Blizzard ID
- logger.info(
- f"Player Cache not found or not up-to-date, fetching from Blizzard: {blizzard_id}"
- )
- html, _ = await fetch_player_html(client, blizzard_id)
-
- # Extract name from HTML for metadata
- name = extract_name_from_profile_html(html)
-
- # Parse career stats
- data = parse_player_career_stats_from_html(
- html, player_summary, platform, gamemode, hero
- )
-
- # Update Player Cache with Blizzard ID as key
- # Progressive enhancement: if cache exists but is missing battletag, update it
- if player_cache and not player_cache.get("battletag") and battletag_input:
- logger.info(f"Updating cache with battletag metadata: {battletag_input}")
-
- await self.update_player_profile_cache(
- blizzard_id,
- player_summary,
- html,
- battletag=battletag_input, # Optional metadata from user's request
- name=name, # Display name extracted from HTML
- )
-
- return data
diff --git a/app/players/controllers/get_player_stats_summary_controller.py b/app/players/controllers/get_player_stats_summary_controller.py
deleted file mode 100644
index 13c3d69e..00000000
--- a/app/players/controllers/get_player_stats_summary_controller.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""Player Stats Summary Controller module"""
-
-from typing import TYPE_CHECKING, ClassVar, cast
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.player_profile import (
- extract_name_from_profile_html,
- fetch_player_html,
-)
-from app.adapters.blizzard.parsers.player_stats import (
- parse_player_stats_summary_from_html,
-)
-from app.config import settings
-from app.exceptions import ParserParsingError
-from app.overfast_logger import logger
-
-if TYPE_CHECKING:
- from app.domain.ports import BlizzardClientPort
- from app.players.enums import PlayerGamemode, PlayerPlatform
-
-from .base_player_controller import BasePlayerController
-
-
-class GetPlayerStatsSummaryController(BasePlayerController):
- """Player Stats Summary Controller used in order to retrieve essential
- stats of a player, often used for tracking progress: winrate, kda, damage, etc.
- """
-
- parser_classes: ClassVar[list] = []
- timeout = settings.career_path_cache_timeout
-
- async def process_request(self, **kwargs) -> dict:
- """Process request with Player Cache support and unknown player guard"""
- player_id = kwargs["player_id"]
- gamemode = kwargs.get("gamemode")
- platform = kwargs.get("platform")
- client = BlizzardClient()
-
- # Initialize variables for exception handling (must be in scope for except block)
- cache_key = player_id
- battletag_input = None
- player_summary = {}
-
- try:
- # Step 1: Resolve player identity (search + Blizzard ID resolution)
- # Returns 4-tuple: (blizzard_id, summary, html, battletag_input)
- (
- blizzard_id,
- player_summary,
- cached_html,
- battletag_input,
- ) = await self._resolve_player_identity(client, player_id)
-
- # Use Blizzard ID as cache key (canonical identifier)
- cache_key = blizzard_id or player_id
-
- # Step 2: Fetch stats with cache optimization
- # Pass cached_html to avoid redundant Blizzard call
- data = await self._fetch_stats_with_cache(
- client,
- cache_key,
- player_summary,
- gamemode,
- platform,
- battletag_input=battletag_input,
- cached_html=cached_html,
- )
-
- except Exception as error: # noqa: BLE001
- # Use shared exception handler (always raises)
- await self.handle_player_request_exceptions(
- error, cache_key, battletag_input, player_summary
- )
-
- # Update API Cache
- await self.cache_manager.update_api_cache(self.cache_key, data, self.timeout)
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
-
- async def _fetch_stats_with_cache(
- self,
- client: BlizzardClientPort,
- blizzard_id: str | None,
- player_summary: dict,
- gamemode: PlayerGamemode | None,
- platform: PlayerPlatform | None,
- battletag_input: str | None = None,
- cached_html: str | None = None,
- ) -> dict:
- """Fetch player stats with cache optimization.
-
- Uses Blizzard ID as the canonical identifier for all caching operations.
- BattleTag is stored as optional metadata but never used as a cache key.
-
- Args:
- client: Blizzard API client
- blizzard_id: Player's Blizzard ID (canonical identifier)
- player_summary: Player summary from search
- gamemode: Optional gamemode filter
- platform: Optional platform filter
- battletag_input: BattleTag from user's request URL (optional metadata)
- cached_html: HTML already fetched during identity resolution
-
- Returns:
- Player stats summary data
- """
- # Step 1 optimization: Use cached HTML from _resolve_player_identity
- if cached_html:
- logger.info(
- "Using cached HTML from identity resolution (avoiding double-fetch)"
- )
- return parse_player_stats_summary_from_html(
- cached_html, player_summary, gamemode, platform
- )
-
- # No Blizzard ID - should not happen but handle defensively
- if not blizzard_id:
- msg = "Unable to resolve player identity"
- logger.warning("No Blizzard ID available, cannot fetch stats")
- raise ParserParsingError(msg)
-
- # Check Player Cache (SQLite storage) - keyed by Blizzard ID
- logger.info(f"Checking Player Cache for Blizzard ID: {blizzard_id}")
- player_cache = await self.get_player_profile_cache(blizzard_id)
-
- if (
- player_cache is not None
- and player_summary
- and player_cache["summary"]["lastUpdated"] # ty: ignore[invalid-argument-type, not-subscriptable]
- == player_summary["lastUpdated"]
- ):
- logger.info("Player Cache found and up-to-date, using it")
- html = cast("str", player_cache["profile"])
- return parse_player_stats_summary_from_html(
- html, player_summary, gamemode, platform
- )
-
- # Fetch from Blizzard using Blizzard ID
- logger.info(
- f"Player Cache not found or not up-to-date, fetching from Blizzard: {blizzard_id}"
- )
- html, _ = await fetch_player_html(client, blizzard_id)
-
- # Extract name from HTML for metadata
- name = extract_name_from_profile_html(html)
-
- # Parse stats
- data = parse_player_stats_summary_from_html(
- html, player_summary, gamemode, platform
- )
-
- # Update Player Cache with Blizzard ID as key
- # Progressive enhancement: if cache exists but is missing battletag, update it
- if player_cache and not player_cache.get("battletag") and battletag_input:
- logger.info(f"Updating cache with battletag metadata: {battletag_input}")
-
- await self.update_player_profile_cache(
- blizzard_id,
- player_summary,
- html,
- battletag=battletag_input, # Optional metadata from user's request
- name=name, # Display name extracted from HTML
- )
-
- return data
diff --git a/app/players/controllers/search_players_controller.py b/app/players/controllers/search_players_controller.py
deleted file mode 100644
index 63015375..00000000
--- a/app/players/controllers/search_players_controller.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Search Players Controller module"""
-
-from typing import ClassVar
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.player_search import parse_player_search
-from app.config import settings
-from app.controllers import AbstractController
-from app.exceptions import ParserParsingError
-from app.helpers import overfast_internal_error
-
-
-class SearchPlayersController(AbstractController):
- """Search Players Controller used in order to find an Overwatch player"""
-
- parser_classes: ClassVar[list] = []
- timeout = settings.search_account_path_cache_timeout
-
- async def process_request(self, **kwargs) -> dict:
- """Process request using stateless parser function"""
- client = BlizzardClient()
-
- try:
- data = await parse_player_search(
- client,
- name=kwargs["name"],
- order_by=kwargs.get("order_by", "name:asc"),
- offset=kwargs.get("offset", 0),
- limit=kwargs.get("limit", 10),
- )
- except ParserParsingError as error:
- # Get Blizzard URL for error reporting
- search_name = kwargs["name"].split("-", 1)[0]
- blizzard_url = (
- f"{settings.blizzard_host}{settings.search_account_path}/{search_name}/"
- )
- raise overfast_internal_error(blizzard_url, error) from error
-
- # Update API Cache
- await self.cache_manager.update_api_cache(self.cache_key, data, self.timeout)
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
diff --git a/app/players/helpers.py b/app/players/helpers.py
index 9fdeb8c1..6aa352cf 100644
--- a/app/players/helpers.py
+++ b/app/players/helpers.py
@@ -2,7 +2,7 @@
import unicodedata
from functools import cache
-from app.helpers import read_csv_data_file
+from app.adapters.csv import CSVReader
from app.roles.enums import Role
from .enums import CareerStatCategory, CompetitiveDivision, CompetitiveRole, HeroKey
@@ -16,7 +16,7 @@
@cache
def get_hero_name(hero_key: HeroKey) -> str: # ty: ignore[invalid-type-form]
"""Get a hero name based on the CSV file"""
- heroes_data = read_csv_data_file("heroes")
+ heroes_data = CSVReader.read_csv_file("heroes")
return next(
(
hero_data["name"]
@@ -160,7 +160,7 @@ def remove_accents(input_str: str) -> str:
@cache
def get_hero_role(hero_key: HeroKey) -> Role | None: # ty: ignore[invalid-type-form]
"""Get the role of a given hero based on the CSV file"""
- heroes_data = read_csv_data_file("heroes")
+ heroes_data = CSVReader.read_csv_file("heroes")
role_key = next(
(
hero_data["role"]
diff --git a/app/roles/controllers/__init__.py b/app/roles/controllers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/roles/controllers/list_roles_controller.py b/app/roles/controllers/list_roles_controller.py
deleted file mode 100644
index be8a2ca6..00000000
--- a/app/roles/controllers/list_roles_controller.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""List Roles Controller module"""
-
-from typing import ClassVar
-
-from app.adapters.blizzard import BlizzardClient
-from app.adapters.blizzard.parsers.roles import parse_roles
-from app.config import settings
-from app.controllers import AbstractController
-from app.enums import Locale
-from app.exceptions import ParserParsingError
-from app.helpers import overfast_internal_error
-
-
-class ListRolesController(AbstractController):
- """List Roles Controller used in order to
- retrieve a list of available Overwatch roles.
- """
-
- parser_classes: ClassVar[list[type]] = []
- timeout = settings.heroes_path_cache_timeout
-
- async def process_request(self, **kwargs) -> list[dict]:
- """Process request using stateless parser function"""
- locale = kwargs.get("locale") or Locale.ENGLISH_US
- client = BlizzardClient()
-
- try:
- data = await parse_roles(client, locale=locale)
- except ParserParsingError as error:
- # Get Blizzard URL for error reporting
- blizzard_url = f"{settings.blizzard_host}/{locale}{settings.home_path}"
- raise overfast_internal_error(blizzard_url, error) from error
-
- # Dual-write to API Cache (Valkey) and Storage (SQLite)
- storage_key = f"roles:{locale}"
- await self.update_static_cache(data, storage_key, data_type="json")
-
- self.response.headers[settings.cache_ttl_header] = str(self.timeout)
-
- return data
diff --git a/app/roles/parsers/__init__.py b/app/roles/parsers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/roles/parsers/roles_parser.py b/app/roles/parsers/roles_parser.py
deleted file mode 100644
index 300229e2..00000000
--- a/app/roles/parsers/roles_parser.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""Roles Parser module"""
-
-from app.config import settings
-from app.parsers import HTMLParser
-
-from ..helpers import get_role_from_icon_url
-
-
-class RolesParser(HTMLParser):
- """Overwatch map gamemodes list page Parser class"""
-
- root_path = settings.home_path
-
- async def parse_data(self) -> list[dict]:
- roles_container = self.root_tag.css_first(
- "div.homepage-features-heroes blz-feature-carousel-section"
- )
-
- roles_icons = [
- role_icon_div.css_first("blz-image").attributes["src"]
- for role_icon_div in roles_container.css_first("blz-tab-controls").css(
- "blz-tab-control"
- )
- ]
-
- return [
- {
- "key": get_role_from_icon_url(roles_icons[role_index]),
- "name": role_div.css_first("blz-header h3").text().capitalize(),
- "icon": roles_icons[role_index],
- "description": (role_div.css_first("blz-header div").text().strip()),
- }
- for role_index, role_div in list(
- enumerate(roles_container.css("blz-feature"))
- )[:3]
- ]
diff --git a/build/grafana/provisioning/dashboards/overfast-api-tasks.json b/build/grafana/provisioning/dashboards/overfast-api-tasks.json
index 8651b7b4..e503631b 100644
--- a/build/grafana/provisioning/dashboards/overfast-api-tasks.json
+++ b/build/grafana/provisioning/dashboards/overfast-api-tasks.json
@@ -418,7 +418,7 @@
},
{
"type": "row",
- "title": "\ud83d\uded1 Rate Limiting",
+ "title": "\u2705 Refresh Outcomes by Entity",
"gridPos": {
"h": 1,
"w": 24,
@@ -428,6 +428,267 @@
"collapsed": false,
"panels": []
},
+ {
+ "type": "stat",
+ "title": "Refreshes Completed (24h)",
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 0,
+ "y": 22
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "targets": [
+ {
+ "expr": "round(sum(increase(background_refresh_completed_total{job=\"fastapi\"}[1d])))",
+ "refId": "A"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "green"
+ },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "value": null,
+ "color": "green"
+ }
+ ]
+ },
+ "decimals": 0
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ },
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "textMode": "auto"
+ }
+ },
+ {
+ "type": "stat",
+ "title": "Refreshes Failed (24h)",
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 6,
+ "y": 22
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "targets": [
+ {
+ "expr": "round(sum(increase(background_refresh_failed_total{job=\"fastapi\"}[1d])))",
+ "refId": "A"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "color": {
+ "mode": "thresholds"
+ },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "value": null,
+ "color": "green"
+ },
+ {
+ "value": 1,
+ "color": "red"
+ }
+ ]
+ },
+ "decimals": 0
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ },
+ "colorMode": "background",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "textMode": "auto"
+ }
+ },
+ {
+ "type": "stat",
+ "title": "Refresh Success Rate (24h)",
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 12,
+ "y": 22
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "targets": [
+ {
+ "expr": "round(100 * sum(increase(background_refresh_completed_total{job=\"fastapi\"}[1d])) / clamp_min(sum(increase(background_refresh_triggered_total{job=\"fastapi\"}[1d])), 1))",
+ "refId": "A"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percent",
+ "color": {
+ "mode": "thresholds"
+ },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "value": null,
+ "color": "red"
+ },
+ {
+ "value": 90,
+ "color": "yellow"
+ },
+ {
+ "value": 99,
+ "color": "green"
+ }
+ ]
+ },
+ "decimals": 1
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ },
+ "colorMode": "background",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "textMode": "auto"
+ }
+ },
+ {
+ "type": "timeseries",
+ "title": "Refresh Completion Rate by Entity",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 26
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "targets": [
+ {
+ "expr": "sum by (entity_type) (rate(background_refresh_completed_total{job=\"fastapi\"}[5m]))",
+ "legendFormat": "{{entity_type}}",
+ "refId": "A"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops",
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 10,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": {
+ "mode": "multi"
+ },
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ }
+ },
+ {
+ "type": "timeseries",
+ "title": "Refresh Failure Rate by Entity",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 26
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "targets": [
+ {
+ "expr": "sum by (entity_type) (rate(background_refresh_failed_total{job=\"fastapi\"}[5m]))",
+ "legendFormat": "{{entity_type}}",
+ "refId": "A"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops",
+ "color": {
+ "fixedColor": "red",
+ "mode": "fixed"
+ },
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 10,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "tooltip": {
+ "mode": "multi"
+ },
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ }
+ },
+ {
+ "type": "row",
+ "title": "\ud83d\uded1 Rate Limiting",
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 34
+ },
+ "collapsed": false,
+ "panels": []
+ },
{
"type": "stat",
"title": "AIMD Current Rate",
@@ -435,7 +696,7 @@
"h": 4,
"w": 8,
"x": 0,
- "y": 22
+ "y": 35
},
"datasource": {
"type": "prometheus",
@@ -485,7 +746,7 @@
"h": 4,
"w": 8,
"x": 8,
- "y": 22
+ "y": 35
},
"datasource": {
"type": "prometheus",
@@ -544,7 +805,7 @@
"h": 4,
"w": 8,
"x": 16,
- "y": 22
+ "y": 35
},
"datasource": {
"type": "prometheus",
@@ -602,7 +863,7 @@
"h": 8,
"w": 12,
"x": 0,
- "y": 26
+ "y": 39
},
"datasource": {
"type": "prometheus",
@@ -643,7 +904,7 @@
"h": 8,
"w": 12,
"x": 12,
- "y": 26
+ "y": 39
},
"datasource": {
"type": "prometheus",
diff --git a/build/nginx/lua/valkey_handler.lua.template b/build/nginx/lua/valkey_handler.lua.template
index adefe382..a8414b72 100644
--- a/build/nginx/lua/valkey_handler.lua.template
+++ b/build/nginx/lua/valkey_handler.lua.template
@@ -1,4 +1,5 @@
local zstd = require "zstd"
+local cjson = require "cjson"
local valkey = require "resty.redis"
local EXCLUDED_PATHS = { ["/"] = true, ["/docs"] = true, ["/openapi.json"] = true }
@@ -96,15 +97,41 @@ local function handle_valkey_request()
return ngx.exec("@fallback")
end
- local ok_decomp, value = pcall(zstd.decompress, compressed_value)
- if not ok_decomp or not value or value == ngx.null then
+ local ok_decomp, decompressed = pcall(zstd.decompress, compressed_value)
+ if not ok_decomp or not decompressed or decompressed == ngx.null then
ngx.log(ngx.ERR, "Cache decompression error for key: ", cache_key)
release(valk)
return ngx.exec("@fallback")
end
+ -- Parse metadata envelope: {"data_json":"...", "stored_at":N, "staleness_threshold":N, "stale_while_revalidate":N}
+ local ok_parse, envelope = pcall(cjson.decode, decompressed)
+ if not ok_parse or type(envelope) ~= "table" or envelope.data_json == nil then
+ ngx.log(ngx.ERR, "Cache envelope parse error for key: ", cache_key)
+ release(valk)
+ return ngx.exec("@fallback")
+ end
+
+ -- Age (RFC 7234 §5.1): seconds since the payload was generated
+ local stored_at = tonumber(envelope.stored_at) or 0
+ local age = math.max(0, ngx.time() - stored_at)
+ ngx.header["Age"] = age
+
+ -- Cache-Control with SWR directives (RFC 5861)
+ local max_age = tonumber(envelope.staleness_threshold) or cache_ttl
+ local swr = tonumber(envelope.stale_while_revalidate) or 0
+ if swr > 0 then
+ ngx.header["Cache-Control"] = "public, max-age=" .. max_age .. ", stale-while-revalidate=" .. swr
+ ngx.header["X-Cache-Status"] = "stale"
+ else
+ ngx.header["Cache-Control"] = "public, max-age=" .. max_age
+ ngx.header["X-Cache-Status"] = "hit"
+ end
+
+ -- X-Cache-TTL: remaining Valkey TTL (non-standard, kept for backward compat)
ngx.header["${CACHE_TTL_HEADER}"] = cache_ttl
- ngx.print(value)
+
+ ngx.print(envelope.data_json)
release(valk)
end
diff --git a/pyproject.toml b/pyproject.toml
index 5437fd73..de67da51 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -136,6 +136,8 @@ allowed-confusables = ["(", ")", ":"]
"tests/**" = ["SLF001"] # Ignore private member access on tests
"models.py" = ["TC001"] # Ignore type-checking block (Pydantic models definition)
"app/api/routers/**" = ["TC001"] # Enums used in FastAPI path/query parameters need runtime import
+"app/api/dependencies.py" = ["TC001"] # Port types needed at runtime for FastAPI Depends
+"app/domain/services/**" = ["TC001"] # Locale/enums used in runtime method annotations
[tool.ruff.lint.isort]
# Consider app as first-party for imports in tests
diff --git a/tests/conftest.py b/tests/conftest.py
index 8fd25f18..c73e937d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,6 +10,7 @@
from fastapi.testclient import TestClient
from app.adapters.storage import SQLiteStorage
+from app.api.dependencies import get_storage
from app.main import app
@@ -45,19 +46,19 @@ async def _patch_before_every_test(
await valkey_server.flushdb()
await storage_db.clear_all_data()
+ app.dependency_overrides[get_storage] = lambda: storage_db
+
with (
patch("app.helpers.settings.discord_webhook_enabled", False),
patch("app.helpers.settings.profiler", None),
patch(
- "app.cache_manager.CacheManager.valkey_server",
+ "app.adapters.cache.valkey_cache.ValkeyCache.valkey_server",
valkey_server,
),
- patch(
- "app.controllers.AbstractController.storage",
- storage_db,
- ),
):
yield
+ app.dependency_overrides.pop(get_storage, None)
+
await valkey_server.flushdb()
await storage_db.clear_all_data()
diff --git a/tests/gamemodes/parsers/conftest.py b/tests/gamemodes/parsers/conftest.py
deleted file mode 100644
index 1e03bdbc..00000000
--- a/tests/gamemodes/parsers/conftest.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import pytest
-
-from app.gamemodes.parsers.gamemodes_parser import GamemodesParser
-
-
-@pytest.fixture(scope="package")
-def gamemodes_parser() -> GamemodesParser:
- return GamemodesParser()
diff --git a/tests/gamemodes/parsers/test_gamemodes_parser.py b/tests/gamemodes/parsers/test_gamemodes_parser.py
index 9fefd576..169a8f28 100644
--- a/tests/gamemodes/parsers/test_gamemodes_parser.py
+++ b/tests/gamemodes/parsers/test_gamemodes_parser.py
@@ -1,26 +1,16 @@
-from typing import TYPE_CHECKING
+from app.adapters.blizzard.parsers.gamemodes import parse_gamemodes_csv
+from app.gamemodes.enums import MapGamemode
-import pytest
-from app.exceptions import OverfastError
+def test_parse_gamemodes_csv_returns_all_gamemodes():
+ result = parse_gamemodes_csv()
+ assert isinstance(result, list)
+ assert len(result) > 0
+ assert {g["key"] for g in result} == {str(g) for g in MapGamemode}
-if TYPE_CHECKING:
- from app.gamemodes.parsers.gamemodes_parser import GamemodesParser
-
-@pytest.mark.asyncio
-async def test_gamemodes_page_parsing(gamemodes_parser: GamemodesParser):
- try:
- await gamemodes_parser.parse()
- except OverfastError:
- pytest.fail("Game modes list parsing failed")
-
- # Just check the format of the first gamemode in the list
- assert isinstance(gamemodes_parser.data, list)
- assert gamemodes_parser.data[0] == {
- "key": "assault",
- "name": "Assault",
- "icon": "https://overfast-api.tekrop.fr/static/gamemodes/assault-icon.svg",
- "description": "Teams fight to capture or defend two successive points against the enemy team. It's an inactive Overwatch 1 gamemode, also called 2CP.",
- "screenshot": "https://overfast-api.tekrop.fr/static/gamemodes/assault.avif",
- }
+def test_parse_gamemodes_csv_entry_format():
+ result = parse_gamemodes_csv()
+ first = result[0]
+ assert set(first.keys()) == {"key", "name", "icon", "description", "screenshot"}
+ assert first["key"] == "assault"
diff --git a/tests/heroes/controllers/__init__.py b/tests/heroes/controllers/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/heroes/controllers/conftest.py b/tests/heroes/controllers/conftest.py
deleted file mode 100644
index 9f73f668..00000000
--- a/tests/heroes/controllers/conftest.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from unittest.mock import MagicMock, Mock, patch
-
-import pytest
-
-from app.heroes.controllers.get_hero_controller import GetHeroController
-
-
-@pytest.fixture(scope="package")
-def get_hero_controller() -> GetHeroController:
- with patch(
- "app.controllers.AbstractController.__init__", MagicMock(return_value=None)
- ):
- return GetHeroController(request=Mock(), response=Mock())
diff --git a/tests/heroes/parsers/conftest.py b/tests/heroes/parsers/conftest.py
deleted file mode 100644
index c89a4404..00000000
--- a/tests/heroes/parsers/conftest.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import pytest
-
-from app.heroes.parsers.hero_parser import HeroParser
-from app.heroes.parsers.hero_stats_summary_parser import HeroStatsSummaryParser
-from app.heroes.parsers.heroes_parser import HeroesParser
-from app.players.enums import PlayerGamemode, PlayerPlatform, PlayerRegion
-
-
-@pytest.fixture(scope="package")
-def hero_parser() -> HeroParser:
- return HeroParser()
-
-
-@pytest.fixture(scope="package")
-def heroes_parser() -> HeroesParser:
- return HeroesParser()
-
-
-@pytest.fixture(scope="package")
-def hero_stats_summary_parser() -> HeroStatsSummaryParser:
- return HeroStatsSummaryParser(
- platform=PlayerPlatform.PC,
- gamemode=PlayerGamemode.COMPETITIVE,
- region=PlayerRegion.EUROPE,
- order_by="hero:asc",
- )
diff --git a/tests/heroes/parsers/test_hero_parser.py b/tests/heroes/parsers/test_hero_parser.py
deleted file mode 100644
index 3776b4e6..00000000
--- a/tests/heroes/parsers/test_hero_parser.py
+++ /dev/null
@@ -1,101 +0,0 @@
-from unittest.mock import Mock, patch
-
-import pytest
-from fastapi import status
-
-from app.config import settings
-from app.enums import Locale
-from app.exceptions import OverfastError, ParserBlizzardError
-from app.heroes.enums import HeroKey
-from app.heroes.parsers.hero_parser import HeroParser
-
-
-@pytest.mark.parametrize(
- ("hero_key", "hero_html_data"),
- [(h, h) for h in HeroKey],
- indirect=["hero_html_data"],
-)
-@pytest.mark.asyncio
-async def test_hero_page_parsing(
- hero_parser: HeroParser,
- hero_key: HeroKey, # ty: ignore[invalid-type-form]
- hero_html_data: str,
-):
- if not hero_html_data:
- pytest.skip("Hero HTML file not saved yet, skipping")
-
- with patch(
- "httpx.AsyncClient.get",
- return_value=Mock(status_code=status.HTTP_200_OK, text=hero_html_data),
- ):
- try:
- await hero_parser.parse()
- except OverfastError:
- pytest.fail(f"Hero page parsing failed for '{hero_key}' hero")
-
-
-@pytest.mark.parametrize("hero_html_data", ["unknown-hero"], indirect=True)
-@pytest.mark.asyncio
-async def test_not_released_hero_parser_blizzard_error(
- hero_parser: HeroParser, hero_html_data: str
-):
- with (
- pytest.raises(ParserBlizzardError),
- patch(
- "httpx.AsyncClient.get",
- return_value=Mock(
- status_code=status.HTTP_404_NOT_FOUND, text=hero_html_data
- ),
- ),
- ):
- await hero_parser.parse()
-
-
-@pytest.mark.parametrize(
- ("url", "full_url"),
- [
- (
- "https://www.youtube.com/watch?v=yzFWIw7wV8Q",
- "https://www.youtube.com/watch?v=yzFWIw7wV8Q",
- ),
- ("/media/stories/bastet", f"{settings.blizzard_host}/media/stories/bastet"),
- ],
-)
-def test_get_full_url(url: str, full_url: str):
- assert HeroParser._get_full_url(url) == full_url
-
-
-@pytest.mark.parametrize(
- ("input_str", "locale", "result"),
- [
- # Classic cases
- ("Aug 19 (Age: 37)", Locale.ENGLISH_US, ("Aug 19", 37)),
- ("May 9 (Age: 1)", Locale.ENGLISH_US, ("May 9", 1)),
- # Specific unknown case (bastion)
- ("Unknown (Age: 32)", Locale.ENGLISH_US, (None, 32)),
- # Specific venture case (not the same spacing)
- ("Aug 6 (Age : 26)", Locale.ENGLISH_US, ("Aug 6", 26)),
- ("Aug 6 (Age : 26)", Locale.ENGLISH_EU, ("Aug 6", 26)),
- # Other languages than english
- ("6. Aug. (Alter: 26)", Locale.GERMAN, ("6. Aug.", 26)),
- ("6 ago (Edad: 26)", Locale.SPANISH_EU, ("6 ago", 26)),
- ("6 ago (Edad: 26)", Locale.SPANISH_LATIN, ("6 ago", 26)),
- ("6 août (Âge : 26 ans)", Locale.FRENCH, ("6 août", 26)),
- ("6 ago (Età: 26)", Locale.ITALIANO, ("6 ago", 26)),
- ("8月6日 (年齢: 26)", Locale.JAPANESE, ("8月6日", 26)),
- ("8월 6일 (나이: 26세)", Locale.KOREAN, ("8월 6일", 26)),
- ("6 sie (Wiek: 26 lat)", Locale.POLISH, ("6 sie", 26)),
- ("6 de ago. (Idade: 26)", Locale.PORTUGUESE_BRAZIL, ("6 de ago.", 26)),
- ("6 авг. (Возраст: 26)", Locale.RUSSIAN, ("6 авг.", 26)),
- ("8月6日 (年齡:26)", Locale.CHINESE_TAIWAN, ("8月6日", 26)),
- # Invalid case
- ("Unknown", Locale.ENGLISH_US, (None, None)),
- ],
-)
-def test_get_birthday_and_age(
- input_str: str,
- locale: Locale,
- result: tuple[str | None, int | None],
-):
- """Get birthday and age from text for a given hero"""
- assert HeroParser._get_birthday_and_age(input_str, locale) == result
diff --git a/tests/heroes/parsers/test_hero_stats_summary.py b/tests/heroes/parsers/test_hero_stats_summary.py
index 128d3ba5..e35b2301 100644
--- a/tests/heroes/parsers/test_hero_stats_summary.py
+++ b/tests/heroes/parsers/test_hero_stats_summary.py
@@ -2,8 +2,13 @@
import pytest
-from app.exceptions import OverfastError, ParserBlizzardError
-from app.heroes.parsers.hero_stats_summary_parser import HeroStatsSummaryParser
+from app.adapters.blizzard import OverFastClient
+from app.adapters.blizzard.parsers.hero_stats_summary import (
+ GAMEMODE_MAPPING,
+ PLATFORM_MAPPING,
+ parse_hero_stats_summary,
+)
+from app.exceptions import ParserBlizzardError
from app.players.enums import (
CompetitiveDivision,
PlayerGamemode,
@@ -14,31 +19,15 @@
@pytest.mark.asyncio
@pytest.mark.parametrize(
- ("parser_init_kwargs", "blizzard_query_params", "raises_error"),
+ ("extra_kwargs", "raises_error"),
[
- # Nominal case
- (
- {},
- {},
- False,
- ),
- # Specific filter (tier)
- (
- {"competitive_division": CompetitiveDivision.DIAMOND},
- {"tier": "Diamond"},
- False,
- ),
- # Invalid map filter (not compatible with competitive)
- (
- {"map": "hanaoka"},
- {"map": "hanaoka"},
- True,
- ),
+ ({}, False),
+ ({"competitive_division": CompetitiveDivision.DIAMOND}, False),
+ ({"map_filter": "hanaoka"}, True),
],
)
-async def test_hero_stats_summary_parser(
- parser_init_kwargs: dict,
- blizzard_query_params: dict,
+async def test_parse_hero_stats_summary(
+ extra_kwargs: dict,
raises_error: bool,
hero_stats_response_mock: Mock,
):
@@ -48,35 +37,72 @@ async def test_hero_stats_summary_parser(
"region": PlayerRegion.EUROPE,
"order_by": "hero:asc",
}
- init_kwargs = base_kwargs | parser_init_kwargs
-
- # Instanciate with given kwargs
- parser = HeroStatsSummaryParser(**init_kwargs)
- # Ensure running the parsing won't fail
with patch("httpx.AsyncClient.get", return_value=hero_stats_response_mock):
+ client = OverFastClient()
if raises_error:
- with pytest.raises(
- ParserBlizzardError,
- match=(
- f"Selected map '{init_kwargs['map']}' is not compatible "
- f"with '{init_kwargs['gamemode']}' gamemode"
- ),
- ):
- await parser.parse()
+ with pytest.raises(ParserBlizzardError):
+ await parse_hero_stats_summary(client, **base_kwargs, **extra_kwargs) # ty: ignore[invalid-argument-type]
else:
- try:
- await parser.parse()
- except OverfastError:
- pytest.fail("Hero stats summary parsing failed")
+ result = await parse_hero_stats_summary(
+ client,
+ **base_kwargs, # ty: ignore[invalid-argument-type]
+ **extra_kwargs,
+ )
+ assert isinstance(result, list)
+ assert len(result) > 0
+ assert "hero" in result[0]
- # Ensure we're sending the right parameters to Blibli
- base_query_params = {
- "input": "PC",
- "rq": "1",
- "region": "Europe",
- "map": "all-maps",
- "tier": "All",
- }
- query_params = base_query_params | blizzard_query_params
- assert parser.get_blizzard_query_params(**init_kwargs) == query_params
+
+@pytest.mark.asyncio
+async def test_parse_hero_stats_summary_query_params(hero_stats_response_mock: Mock):
+ """Verify the exact Blizzard query parameters built by the parser."""
+ platform = PlayerPlatform.PC
+ gamemode = PlayerGamemode.COMPETITIVE
+ region = PlayerRegion.EUROPE
+ division = CompetitiveDivision.DIAMOND
+ map_key = "all-maps"
+
+ with patch(
+ "httpx.AsyncClient.get", return_value=hero_stats_response_mock
+ ) as mock_get:
+ client = OverFastClient()
+ await parse_hero_stats_summary(
+ client,
+ platform=platform,
+ gamemode=gamemode,
+ region=region,
+ competitive_division=division,
+ map_filter=map_key,
+ order_by="hero:asc",
+ )
+
+ mock_get.assert_called_once()
+ _, kwargs = mock_get.call_args
+ params = kwargs.get("params", {})
+
+ assert params["input"] == PLATFORM_MAPPING[platform]
+ assert params["rq"] == GAMEMODE_MAPPING[gamemode]
+ assert params["region"] == region.capitalize()
+ assert params["map"] == map_key
+ assert params["tier"] == division.capitalize()
+
+
+@pytest.mark.asyncio
+async def test_parse_hero_stats_summary_invalid_map_error_message(
+ hero_stats_response_mock: Mock,
+):
+ """ParserBlizzardError message should name the incompatible map."""
+ with patch("httpx.AsyncClient.get", return_value=hero_stats_response_mock):
+ client = OverFastClient()
+ with pytest.raises(ParserBlizzardError) as exc_info:
+ await parse_hero_stats_summary(
+ client,
+ platform=PlayerPlatform.PC,
+ gamemode=PlayerGamemode.COMPETITIVE,
+ region=PlayerRegion.EUROPE,
+ map_filter="hanaoka",
+ )
+
+ assert "hanaoka" in exc_info.value.message
+ assert "compatible" in exc_info.value.message.lower()
diff --git a/tests/heroes/parsers/test_heroes_parser.py b/tests/heroes/parsers/test_heroes_parser.py
index 41ef036b..2fae076a 100644
--- a/tests/heroes/parsers/test_heroes_parser.py
+++ b/tests/heroes/parsers/test_heroes_parser.py
@@ -1,26 +1,55 @@
-from typing import TYPE_CHECKING
from unittest.mock import Mock, patch
import pytest
from fastapi import status
-from app.exceptions import OverfastError
-from app.heroes.enums import HeroKey
+from app.adapters.blizzard import OverFastClient
+from app.adapters.blizzard.parsers.heroes import (
+ fetch_heroes_html,
+ filter_heroes,
+ parse_heroes_html,
+)
+from app.heroes.enums import HeroGamemode, HeroKey
+from app.roles.enums import Role
-if TYPE_CHECKING:
- from app.heroes.parsers.heroes_parser import HeroesParser
+
+def test_parse_heroes_html_returns_all_heroes(heroes_html_data: str):
+ result = parse_heroes_html(heroes_html_data)
+ assert isinstance(result, list)
+ assert all(hero["key"] in iter(HeroKey) for hero in result)
+
+
+def test_parse_heroes_html_entry_format(heroes_html_data: str):
+ result = parse_heroes_html(heroes_html_data)
+ first = result[0]
+ assert set(first.keys()) == {"key", "name", "portrait", "role", "gamemodes"}
+
+
+def test_filter_heroes_by_role(heroes_html_data: str):
+ heroes = parse_heroes_html(heroes_html_data)
+ filtered = filter_heroes(heroes, role=Role.TANK, gamemode=None)
+ assert all(h["role"] == Role.TANK for h in filtered)
+ assert len(filtered) < len(heroes)
+
+
+def test_filter_heroes_by_gamemode(heroes_html_data: str):
+ heroes = parse_heroes_html(heroes_html_data)
+ filtered = filter_heroes(heroes, role=None, gamemode=HeroGamemode.STADIUM)
+ assert len(filtered) <= len(heroes)
+
+
+def test_filter_heroes_no_filter(heroes_html_data: str):
+ heroes = parse_heroes_html(heroes_html_data)
+ assert filter_heroes(heroes, role=None, gamemode=None) == heroes
@pytest.mark.asyncio
-async def test_heroes_page_parsing(heroes_parser: HeroesParser, heroes_html_data: str):
+async def test_fetch_heroes_html_calls_blizzard(heroes_html_data: str):
with patch(
"httpx.AsyncClient.get",
return_value=Mock(status_code=status.HTTP_200_OK, text=heroes_html_data),
):
- try:
- await heroes_parser.parse()
- except OverfastError:
- pytest.fail("Heroes list parsing failed")
+ client = OverFastClient()
+ html = await fetch_heroes_html(client)
- assert isinstance(heroes_parser.data, list)
- assert all(hero["key"] in iter(HeroKey) for hero in heroes_parser.data)
+ assert html == heroes_html_data
diff --git a/app/gamemodes/controllers/__init__.py b/tests/heroes/services/__init__.py
similarity index 100%
rename from app/gamemodes/controllers/__init__.py
rename to tests/heroes/services/__init__.py
diff --git a/tests/heroes/controllers/test_heroes_controllers.py b/tests/heroes/services/test_hero_service.py
similarity index 81%
rename from tests/heroes/controllers/test_heroes_controllers.py
rename to tests/heroes/services/test_hero_service.py
index 6e244e96..a480af1f 100644
--- a/tests/heroes/controllers/test_heroes_controllers.py
+++ b/tests/heroes/services/test_hero_service.py
@@ -2,7 +2,7 @@
import pytest
-from app.heroes.controllers.get_hero_controller import GetHeroController
+from app.domain.services.hero_service import dict_insert_value_before_key
@pytest.mark.parametrize(
@@ -21,9 +21,7 @@ def test_dict_insert_value_before_key_with_key_error(
new_value: Any,
):
with pytest.raises(KeyError):
- GetHeroController._dict_insert_value_before_key(
- input_dict, key, new_key, new_value
- )
+ dict_insert_value_before_key(input_dict, key, new_key, new_value)
@pytest.mark.parametrize(
@@ -63,8 +61,5 @@ def test_dict_insert_value_before_key_valid(
result_dict: dict,
):
assert (
- GetHeroController._dict_insert_value_before_key(
- input_dict, key, new_key, new_value
- )
- == result_dict
+ dict_insert_value_before_key(input_dict, key, new_key, new_value) == result_dict
)
diff --git a/tests/heroes/test_hero_routes.py b/tests/heroes/test_hero_routes.py
index 1687f31e..ce96fa81 100644
--- a/tests/heroes/test_hero_routes.py
+++ b/tests/heroes/test_hero_routes.py
@@ -72,8 +72,8 @@ def test_get_hero_blizzard_error(client: TestClient):
def test_get_hero_internal_error(client: TestClient):
with patch(
- "app.heroes.controllers.get_hero_controller.GetHeroController.process_request",
- return_value={"invalid_key": "invalid_value"},
+ "app.domain.services.hero_service.HeroService.get_hero",
+ return_value=({"invalid_key": "invalid_value"}, False, 0),
):
response = client.get(f"/heroes/{HeroKey.ANA}") # ty: ignore[unresolved-attribute]
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -119,7 +119,7 @@ def test_get_hero_no_portrait(
],
),
patch(
- "app.adapters.blizzard.parsers.heroes.filter_heroes",
+ "app.domain.services.hero_service.parse_heroes_html",
return_value=[],
),
):
diff --git a/tests/heroes/test_hero_stats_route.py b/tests/heroes/test_hero_stats_route.py
index 9cbb0a37..408e8b16 100644
--- a/tests/heroes/test_hero_stats_route.py
+++ b/tests/heroes/test_hero_stats_route.py
@@ -96,8 +96,8 @@ def test_get_hero_stats_blizzard_error(client: TestClient):
def test_get_heroes_internal_error(client: TestClient):
with patch(
- "app.heroes.controllers.get_hero_stats_summary_controller.GetHeroStatsSummaryController.process_request",
- return_value=[{"invalid_key": "invalid_value"}],
+ "app.domain.services.hero_service.HeroService.get_hero_stats",
+ return_value=([{"invalid_key": "invalid_value"}], False, 0),
):
response = client.get(
"/heroes/stats",
diff --git a/tests/heroes/test_heroes_route.py b/tests/heroes/test_heroes_route.py
index e12d4147..5fd2cfdc 100644
--- a/tests/heroes/test_heroes_route.py
+++ b/tests/heroes/test_heroes_route.py
@@ -69,8 +69,8 @@ def test_get_heroes_blizzard_error(client: TestClient):
def test_get_heroes_internal_error(client: TestClient):
with patch(
- "app.heroes.controllers.list_heroes_controller.ListHeroesController.process_request",
- return_value=[{"invalid_key": "invalid_value"}],
+ "app.domain.services.hero_service.HeroService.list_heroes",
+ return_value=([{"invalid_key": "invalid_value"}], False, 0),
):
response = client.get("/heroes")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
diff --git a/tests/maps/parsers/conftest.py b/tests/maps/parsers/conftest.py
deleted file mode 100644
index 45e88b9e..00000000
--- a/tests/maps/parsers/conftest.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import pytest
-
-from app.maps.parsers.maps_parser import MapsParser
-
-
-@pytest.fixture(scope="package")
-def maps_parser() -> MapsParser:
- return MapsParser()
diff --git a/tests/maps/parsers/test_maps_parser.py b/tests/maps/parsers/test_maps_parser.py
index 14bb2c31..c776e717 100644
--- a/tests/maps/parsers/test_maps_parser.py
+++ b/tests/maps/parsers/test_maps_parser.py
@@ -1,27 +1,24 @@
-from typing import TYPE_CHECKING
+from app.adapters.blizzard.parsers.maps import parse_maps_csv
+from app.maps.enums import MapKey
-import pytest
-from app.exceptions import OverfastError
+def test_parse_maps_csv_returns_all_maps():
+ result = parse_maps_csv()
+ assert isinstance(result, list)
+ assert len(result) > 0
+ assert {m["key"] for m in result} == {str(m) for m in MapKey}
-if TYPE_CHECKING:
- from app.maps.parsers.maps_parser import MapsParser
-
-@pytest.mark.asyncio
-async def test_maps_page_parsing(maps_parser: MapsParser):
- try:
- await maps_parser.parse()
- except OverfastError:
- pytest.fail("Maps list parsing failed")
-
- # Just check the format of the first map in the list
- assert isinstance(maps_parser.data, list)
- assert maps_parser.data[0] == {
- "key": "aatlis",
- "name": "Aatlis",
- "screenshot": "https://overfast-api.tekrop.fr/static/maps/aatlis.jpg",
- "gamemodes": ["flashpoint"],
- "location": "Morocco",
- "country_code": "MA",
+def test_parse_maps_csv_entry_format():
+ result = parse_maps_csv()
+ first = result[0]
+ assert set(first.keys()) == {
+ "key",
+ "name",
+ "screenshot",
+ "gamemodes",
+ "location",
+ "country_code",
}
+ assert first["key"] == "aatlis"
+ assert isinstance(first["gamemodes"], list)
diff --git a/tests/players/test_player_career_route.py b/tests/players/test_player_career_route.py
index c3ea3604..70845fa3 100644
--- a/tests/players/test_player_career_route.py
+++ b/tests/players/test_player_career_route.py
@@ -97,8 +97,8 @@ def test_get_player_career_blizzard_remote_protocol_error(client: TestClient):
def test_get_player_career_internal_error(client: TestClient):
with patch(
- "app.players.controllers.get_player_career_controller.GetPlayerCareerController.process_request",
- return_value={"invalid_key": "invalid_value"},
+ "app.domain.services.player_service.PlayerService.get_player_career",
+ return_value=({"invalid_key": "invalid_value"}, False, 0),
):
response = client.get("/players/TeKrop-2217")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
diff --git a/tests/players/test_player_stats_route.py b/tests/players/test_player_stats_route.py
index 507c299e..4ee57608 100644
--- a/tests/players/test_player_stats_route.py
+++ b/tests/players/test_player_stats_route.py
@@ -200,11 +200,11 @@ def test_get_player_stats_blizzard_forbidden_error(client: TestClient, uri: str)
[
(
"/stats",
- "app.players.controllers.get_player_career_controller.GetPlayerCareerController.process_request",
+ "app.domain.services.player_service.PlayerService.get_player_stats",
),
(
"/stats/career",
- "app.players.controllers.get_player_career_stats_controller.GetPlayerCareerStatsController.process_request",
+ "app.domain.services.player_service.PlayerService.get_player_career_stats",
),
],
)
@@ -213,9 +213,13 @@ def test_get_player_stats_internal_error(
):
with patch(
patch_target,
- return_value={
- "ana": [{"category": "invalid_value", "stats": [{"key": "test"}]}],
- },
+ return_value=(
+ {
+ "ana": [{"category": "invalid_value", "stats": [{"key": "test"}]}],
+ },
+ False,
+ 0,
+ ),
):
response = client.get(
f"/players/TeKrop-2217{uri}",
diff --git a/tests/players/test_player_stats_summary_route.py b/tests/players/test_player_stats_summary_route.py
index ee6da96a..cb00df54 100644
--- a/tests/players/test_player_stats_summary_route.py
+++ b/tests/players/test_player_stats_summary_route.py
@@ -130,10 +130,14 @@ def test_get_player_stats_summary_blizzard_timeout(client: TestClient):
@pytest.mark.parametrize("player_html_data", ["TeKrop-2217"], indirect=True)
def test_get_player_stats_summary_internal_error(client: TestClient):
with patch(
- "app.players.controllers.get_player_stats_summary_controller.GetPlayerStatsSummaryController.process_request",
- return_value={
- "general": [{"category": "invalid_value", "stats": [{"key": "test"}]}],
- },
+ "app.domain.services.player_service.PlayerService.get_player_stats_summary",
+ return_value=(
+ {
+ "general": [{"category": "invalid_value", "stats": [{"key": "test"}]}],
+ },
+ False,
+ 0,
+ ),
):
response = client.get(
"/players/TeKrop-2217/stats/summary",
diff --git a/tests/players/test_player_summary_route.py b/tests/players/test_player_summary_route.py
index 7cc4f365..03795b95 100644
--- a/tests/players/test_player_summary_route.py
+++ b/tests/players/test_player_summary_route.py
@@ -74,8 +74,8 @@ def test_get_player_summary_blizzard_timeout(client: TestClient):
def test_get_player_summary_internal_error(client: TestClient):
with patch(
- "app.players.controllers.get_player_career_controller.GetPlayerCareerController.process_request",
- return_value={"invalid_key": "invalid_value"},
+ "app.domain.services.player_service.PlayerService.get_player_summary",
+ return_value=({"invalid_key": "invalid_value"}, False, 0),
):
response = client.get("/players/TeKrop-2217/summary")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
diff --git a/tests/players/test_search_players_route.py b/tests/players/test_search_players_route.py
index d0c06244..38049ae3 100644
--- a/tests/players/test_search_players_route.py
+++ b/tests/players/test_search_players_route.py
@@ -161,7 +161,7 @@ def test_search_players_ordering(
def test_search_players_internal_error(client: TestClient):
with patch(
- "app.players.controllers.search_players_controller.SearchPlayersController.process_request",
+ "app.domain.services.player_service.PlayerService.search_players",
return_value={"invalid_key": "invalid_value"},
):
response = client.get("/players", params={"name": "Test"})
diff --git a/tests/roles/parsers/conftest.py b/tests/roles/parsers/conftest.py
deleted file mode 100644
index fe89a99b..00000000
--- a/tests/roles/parsers/conftest.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import pytest
-
-from app.roles.parsers.roles_parser import RolesParser
-
-
-@pytest.fixture(scope="package")
-def roles_parser() -> RolesParser:
- return RolesParser()
diff --git a/tests/roles/parsers/test_roles_parser.py b/tests/roles/parsers/test_roles_parser.py
index 8fd72321..8ece049d 100644
--- a/tests/roles/parsers/test_roles_parser.py
+++ b/tests/roles/parsers/test_roles_parser.py
@@ -1,26 +1,32 @@
-from typing import TYPE_CHECKING
from unittest.mock import Mock, patch
import pytest
from fastapi import status
-from app.exceptions import OverfastError
+from app.adapters.blizzard import OverFastClient
+from app.adapters.blizzard.parsers.roles import fetch_roles_html, parse_roles_html
from app.roles.enums import Role
-if TYPE_CHECKING:
- from app.roles.parsers.roles_parser import RolesParser
+
+def test_parse_roles_html_returns_all_roles(home_html_data: str):
+ result = parse_roles_html(home_html_data)
+ assert isinstance(result, list)
+ assert {role["key"] for role in result} == {r.value for r in Role}
+
+
+def test_parse_roles_html_entry_format(home_html_data: str):
+ result = parse_roles_html(home_html_data)
+ first = result[0]
+ assert set(first.keys()) == {"key", "name", "icon", "description"}
@pytest.mark.asyncio
-async def test_roles_page_parsing(roles_parser: RolesParser, home_html_data: str):
+async def test_fetch_roles_html_calls_blizzard(home_html_data: str):
with patch(
"httpx.AsyncClient.get",
return_value=Mock(status_code=status.HTTP_200_OK, text=home_html_data),
):
- try:
- await roles_parser.parse()
- except OverfastError:
- pytest.fail("Roles list parsing failed")
+ client = OverFastClient()
+ html = await fetch_roles_html(client)
- assert isinstance(roles_parser.data, list)
- assert {role["key"] for role in roles_parser.data} == {r.value for r in Role}
+ assert html == home_html_data
diff --git a/tests/roles/test_roles_route.py b/tests/roles/test_roles_route.py
index 5f533d0c..4a225740 100644
--- a/tests/roles/test_roles_route.py
+++ b/tests/roles/test_roles_route.py
@@ -36,8 +36,8 @@ def test_get_roles_blizzard_error(client: TestClient):
def test_get_roles_internal_error(client: TestClient):
with patch(
- "app.roles.controllers.list_roles_controller.ListRolesController.process_request",
- return_value=[{"invalid_key": "invalid_value"}],
+ "app.domain.services.role_service.RoleService.list_roles",
+ return_value=([{"invalid_key": "invalid_value"}], False, 0),
):
response = client.get("/roles")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
diff --git a/tests/test_asyncio_task_queue.py b/tests/test_asyncio_task_queue.py
new file mode 100644
index 00000000..29388477
--- /dev/null
+++ b/tests/test_asyncio_task_queue.py
@@ -0,0 +1,264 @@
+"""Tests for AsyncioTaskQueue adapter"""
+
+import asyncio
+
+import pytest
+
+from app.adapters.tasks.asyncio_task_queue import AsyncioTaskQueue
+from app.metaclasses import Singleton
+from app.monitoring.metrics import (
+ background_tasks_queue_size,
+ background_tasks_total,
+)
+
+
+@pytest.fixture(autouse=True)
+def _reset_task_queue():
+ """Reset Singleton and pending jobs before each test for isolation."""
+ Singleton._instances.pop(AsyncioTaskQueue, None)
+ AsyncioTaskQueue._pending_jobs.clear()
+ yield
+ AsyncioTaskQueue._pending_jobs.clear()
+ Singleton._instances.pop(AsyncioTaskQueue, None)
+
+
+class TestSingleton:
+ def test_same_instance_returned(self):
+ q1 = AsyncioTaskQueue()
+ q2 = AsyncioTaskQueue()
+ assert q1 is q2
+
+ def test_shared_pending_jobs_set(self):
+ q1 = AsyncioTaskQueue()
+ q2 = AsyncioTaskQueue()
+ assert q1._pending_jobs is q2._pending_jobs
+
+
+class TestDeduplication:
+ @pytest.mark.asyncio
+ async def test_duplicate_job_skipped(self):
+ queue = AsyncioTaskQueue()
+ completed = []
+
+ async def work():
+ await asyncio.sleep(0.05)
+ completed.append(1)
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await queue.enqueue("refresh", job_id="job-1") # duplicate — no coro
+
+ await asyncio.sleep(0.1)
+ assert len(completed) == 1
+
+ @pytest.mark.asyncio
+ async def test_duplicate_coro_closed(self):
+ """The second coroutine must be closed to prevent 'never awaited' warning."""
+ queue = AsyncioTaskQueue()
+
+ async def work():
+ await asyncio.sleep(0.05)
+
+ async def second():
+ pass # never should run
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+
+ # Enqueue with a coro while job-1 is still running
+ coro = second()
+ await queue.enqueue("refresh", job_id="job-1", coro=coro)
+
+ # coro should be closed (not just pending)
+ assert coro.cr_frame is None # closed coroutine has no frame
+
+ await asyncio.sleep(0.1)
+
+ @pytest.mark.asyncio
+ async def test_same_job_id_requeued_after_completion(self):
+ """After a job finishes, the same job_id can be enqueued again."""
+ queue = AsyncioTaskQueue()
+ completed = []
+
+ async def work():
+ completed.append(1)
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await asyncio.sleep(0.05)
+ assert len(completed) == 1
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await asyncio.sleep(0.05)
+ assert len(completed) == 2 # noqa: PLR2004
+
+ @pytest.mark.asyncio
+ async def test_different_job_ids_both_run(self):
+ queue = AsyncioTaskQueue()
+ completed = []
+
+ async def work(name: str):
+ completed.append(name)
+
+ await queue.enqueue("refresh", job_id="job-a", coro=work("a"))
+ await queue.enqueue("refresh", job_id="job-b", coro=work("b"))
+ await asyncio.sleep(0.05)
+
+ assert sorted(completed) == ["a", "b"]
+
+
+class TestCallbacks:
+ @pytest.mark.asyncio
+ async def test_on_complete_called_on_success(self):
+ queue = AsyncioTaskQueue()
+ completed_ids = []
+
+ async def work():
+ pass
+
+ async def on_complete(job_id: str) -> None:
+ completed_ids.append(job_id)
+
+ await queue.enqueue(
+ "refresh", job_id="job-1", coro=work(), on_complete=on_complete
+ )
+ await asyncio.sleep(0.05)
+
+ assert completed_ids == ["job-1"]
+
+ @pytest.mark.asyncio
+ async def test_on_failure_called_on_error(self):
+ queue = AsyncioTaskQueue()
+ failures: list[tuple[str, Exception]] = []
+
+ async def failing_work():
+ msg = "boom"
+ raise ValueError(msg)
+
+ async def on_failure(job_id: str, exc: Exception) -> None:
+ failures.append((job_id, exc))
+
+ await queue.enqueue(
+ "refresh", job_id="job-1", coro=failing_work(), on_failure=on_failure
+ )
+ await asyncio.sleep(0.05)
+
+ assert len(failures) == 1
+ assert failures[0][0] == "job-1"
+ assert isinstance(failures[0][1], ValueError)
+
+ @pytest.mark.asyncio
+ async def test_on_complete_not_called_on_failure(self):
+ queue = AsyncioTaskQueue()
+ completed_ids = []
+
+ async def failing_work():
+ msg = "fail"
+ raise RuntimeError(msg)
+
+ async def on_complete(job_id: str) -> None:
+ completed_ids.append(job_id)
+
+ await queue.enqueue(
+ "refresh", job_id="job-1", coro=failing_work(), on_complete=on_complete
+ )
+ await asyncio.sleep(0.05)
+
+ assert completed_ids == []
+
+ @pytest.mark.asyncio
+ async def test_no_callbacks_no_error(self):
+ """Enqueue without callbacks should run cleanly with no exception."""
+ queue = AsyncioTaskQueue()
+
+ async def work():
+ pass
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await asyncio.sleep(0.05)
+
+
+class TestMetrics:
+ @pytest.mark.asyncio
+ async def test_queue_size_increments_then_decrements(self):
+ queue = AsyncioTaskQueue()
+ sizes_during: list[float] = []
+
+ async def work():
+ val = background_tasks_queue_size.labels(task_type="refresh")._value.get()
+ sizes_during.append(val)
+ await asyncio.sleep(0.01)
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await asyncio.sleep(0.05)
+
+ assert sizes_during[0] == 1.0
+ after = background_tasks_queue_size.labels(task_type="refresh")._value.get()
+ assert after == 0.0
+
+ @pytest.mark.asyncio
+ async def test_tasks_total_success(self):
+ queue = AsyncioTaskQueue()
+
+ async def work():
+ pass
+
+ before = background_tasks_total.labels(
+ task_type="refresh", status="success"
+ )._value.get()
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await asyncio.sleep(0.05)
+ after = background_tasks_total.labels(
+ task_type="refresh", status="success"
+ )._value.get()
+
+ assert after == before + 1
+
+ @pytest.mark.asyncio
+ async def test_tasks_total_failure(self):
+ queue = AsyncioTaskQueue()
+
+ async def failing_work():
+ msg = "fail"
+ raise ValueError(msg)
+
+ before = background_tasks_total.labels(
+ task_type="refresh", status="failure"
+ )._value.get()
+ await queue.enqueue("refresh", job_id="job-1", coro=failing_work())
+ await asyncio.sleep(0.05)
+ after = background_tasks_total.labels(
+ task_type="refresh", status="failure"
+ )._value.get()
+
+ assert after == before + 1
+
+
+class TestIsJobPendingOrRunning:
+ @pytest.mark.asyncio
+ async def test_pending_while_running(self):
+ queue = AsyncioTaskQueue()
+ is_pending_during: list[bool] = []
+
+ async def work():
+ is_pending_during.append(await queue.is_job_pending_or_running("job-1"))
+ await asyncio.sleep(0.02)
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await asyncio.sleep(0.05)
+
+ assert is_pending_during == [True]
+
+ @pytest.mark.asyncio
+ async def test_not_pending_after_completion(self):
+ queue = AsyncioTaskQueue()
+
+ async def work():
+ pass
+
+ await queue.enqueue("refresh", job_id="job-1", coro=work())
+ await asyncio.sleep(0.05)
+
+ assert not await queue.is_job_pending_or_running("job-1")
+
+ @pytest.mark.asyncio
+ async def test_not_pending_for_unknown_job(self):
+ queue = AsyncioTaskQueue()
+ assert not await queue.is_job_pending_or_running("unknown-job")
diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py
index 900ad9dc..09b4824a 100644
--- a/tests/test_cache_manager.py
+++ b/tests/test_cache_manager.py
@@ -5,7 +5,7 @@
import pytest
from valkey.exceptions import ValkeyError
-from app.cache_manager import CacheManager
+from app.adapters.cache import CacheManager
from app.config import settings
from app.enums import Locale
diff --git a/uv.lock b/uv.lock
index 5a6c718e..f6ba5356 100644
--- a/uv.lock
+++ b/uv.lock
@@ -604,7 +604,7 @@ wheels = [
[[package]]
name = "overfast-api"
-version = "3.42.1"
+version = "3.43.0"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },