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" },