├── app ├── __init__.py ├── heroes │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ └── check_new_hero.py │ ├── parsers │ │ ├── __init__.py │ │ ├── heroes_stats_parser.py │ │ └── heroes_parser.py │ ├── controllers │ │ ├── __init__.py │ │ ├── list_heroes_controller.py │ │ ├── get_hero_stats_summary_controller.py │ │ └── get_hero_controller.py │ ├── enums.py │ ├── data │ │ └── heroes.csv │ └── router.py ├── maps │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ └── maps_parser.py │ ├── controllers │ │ ├── __init__.py │ │ └── list_maps_controller.py │ ├── enums.py │ ├── models.py │ ├── router.py │ └── data │ │ └── maps.csv ├── players │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── search_data_parser.py │ │ ├── player_career_stats_parser.py │ │ ├── base_player_parser.py │ │ └── player_search_parser.py │ ├── controllers │ │ ├── __init__.py │ │ ├── search_players_controller.py │ │ ├── get_player_career_stats_controller.py │ │ ├── get_player_career_controller.py │ │ └── get_player_stats_summary_controller.py │ ├── exceptions.py │ └── enums.py ├── roles │ ├── __init__.py │ ├── controllers │ │ ├── __init__.py │ │ └── list_roles_controller.py │ ├── parsers │ │ ├── __init__.py │ │ └── roles_parser.py │ ├── helpers.py │ ├── enums.py │ ├── models.py │ └── router.py ├── gamemodes │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ └── gamemodes_parser.py │ ├── controllers │ │ ├── __init__.py │ │ └── list_gamemodes_controller.py │ ├── enums.py │ ├── router.py │ ├── models.py │ └── data │ │ └── gamemodes.csv ├── metaclasses.py ├── models.py ├── enums.py ├── exceptions.py ├── decorators.py ├── overfast_logger.py ├── docs.py ├── controllers.py ├── helpers.py └── overfast_client.py ├── tests ├── __init__.py ├── maps │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_maps_parser.py │ └── test_maps_route.py ├── roles │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_roles_parser.py │ ├── conftest.py │ └── test_roles_route.py ├── gamemodes │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_gamemodes_parser.py │ └── test_gamemodes_route.py ├── heroes │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ └── test_check_new_hero.py │ ├── parsers │ │ ├── __init__.py │ │ ├── test_heroes_parser.py │ │ ├── conftest.py │ │ ├── test_hero_stats_summary.py │ │ └── test_hero_parser.py │ ├── controllers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_heroes_controllers.py │ ├── conftest.py │ ├── test_heroes_route.py │ └── test_hero_stats_route.py ├── players │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_player_stats_summary_parser.py │ │ └── test_player_career_stats_parser.py │ ├── conftest.py │ └── test_player_summary_route.py ├── test_helpers.py ├── test_documentation_route.py ├── conftest.py ├── helpers.py ├── test_decorators.py ├── test_cache_manager.py ├── test_update_test_fixtures.py └── update_test_fixtures.py ├── static ├── logo.png ├── favicon.ico ├── favicon.png ├── logo_light.png ├── maps │ ├── busan.jpg │ ├── ilios.jpg │ ├── nepal.jpg │ ├── oasis.jpg │ ├── paris.jpg │ ├── petra.jpg │ ├── samoa.jpg │ ├── aatlis.jpg │ ├── anubis.jpg │ ├── castillo.jpg │ ├── colosseo.jpg │ ├── dorado.jpg │ ├── gogadoro.jpg │ ├── hanamura.jpg │ ├── hanaoka.jpg │ ├── havana.jpg │ ├── horizon.jpg │ ├── kanezaka.jpg │ ├── midtown.jpg │ ├── numbani.jpg │ ├── paraiso.jpg │ ├── rialto.jpg │ ├── route-66.jpg │ ├── runasapi.jpg │ ├── suravasa.jpg │ ├── volskaya.jpg │ ├── ayutthaya.jpg │ ├── esperanca.jpg │ ├── hollywood.jpg │ ├── junkertown.jpg │ ├── kings-row.jpg │ ├── malevento.jpg │ ├── necropolis.jpg │ ├── black-forest.jpg │ ├── circuit-royal.jpg │ ├── eichenwalde.jpg │ ├── lijiang-tower.jpg │ ├── new-junk-city.jpg │ ├── place-lacroix.jpg │ ├── redwood-dam.jpg │ ├── arena-victoriae.jpg │ ├── blizzard-world.jpg │ ├── powder-keg-mine.jpg │ ├── practice-range.jpg │ ├── thames-district.jpg │ ├── workshop-island.jpg │ ├── chateau-guillard.jpg │ ├── new-queen-street.jpg │ ├── shambali-monastery.jpg │ ├── throne-of-anubis.jpg │ ├── workshop-chamber.jpg │ ├── workshop-expanse.jpg │ ├── antarctic-peninsula.jpg │ ├── ecopoint-antarctica.jpg │ ├── watchpoint-gibraltar.jpg │ └── workshop-green-screen.jpg ├── gamemodes │ ├── push.avif │ ├── assault.avif │ ├── clash.avif │ ├── control.avif │ ├── escort.avif │ ├── hybrid.avif │ ├── workshop.avif │ ├── deathmatch.avif │ ├── elimination.avif │ ├── flashpoint.avif │ ├── payload-race.avif │ ├── practice-range.avif │ ├── capture-the-flag.avif │ ├── team-deathmatch.avif │ ├── assault-icon.svg │ ├── capture-the-flag-icon.svg │ ├── clash-icon.svg │ ├── control-icon.svg │ ├── escort-icon.svg │ ├── payload-race-icon.svg │ ├── flashpoint-icon.svg │ ├── push-icon.svg │ ├── hybrid-icon.svg │ └── elimination-icon.svg └── overwatch-redoc.css ├── SECURITY.md ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── release.yaml │ └── build.yml ├── .pre-commit-config.yaml ├── .env.dist ├── Dockerfile ├── LICENSE ├── .gitignore ├── docker-compose.yml ├── justfile ├── CONTRIBUTING.md ├── AGENTS.md └── Makefile /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/heroes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/maps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/players/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/roles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/maps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/roles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/gamemodes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/maps/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gamemodes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/heroes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/players/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/gamemodes/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/heroes/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/heroes/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/maps/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/players/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/roles/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/roles/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/heroes/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/heroes/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/maps/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/players/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/roles/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/gamemodes/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/heroes/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/players/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gamemodes/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/heroes/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/logo.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/favicon.png -------------------------------------------------------------------------------- /static/logo_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/logo_light.png -------------------------------------------------------------------------------- /static/maps/busan.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/busan.jpg -------------------------------------------------------------------------------- /static/maps/ilios.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/ilios.jpg -------------------------------------------------------------------------------- /static/maps/nepal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/nepal.jpg -------------------------------------------------------------------------------- /static/maps/oasis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/oasis.jpg -------------------------------------------------------------------------------- /static/maps/paris.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/paris.jpg -------------------------------------------------------------------------------- /static/maps/petra.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/petra.jpg -------------------------------------------------------------------------------- /static/maps/samoa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/samoa.jpg -------------------------------------------------------------------------------- /static/maps/aatlis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/aatlis.jpg -------------------------------------------------------------------------------- /static/maps/anubis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/anubis.jpg -------------------------------------------------------------------------------- /static/maps/castillo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/castillo.jpg -------------------------------------------------------------------------------- /static/maps/colosseo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/colosseo.jpg -------------------------------------------------------------------------------- /static/maps/dorado.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/dorado.jpg -------------------------------------------------------------------------------- /static/maps/gogadoro.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/gogadoro.jpg -------------------------------------------------------------------------------- /static/maps/hanamura.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/hanamura.jpg -------------------------------------------------------------------------------- /static/maps/hanaoka.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/hanaoka.jpg -------------------------------------------------------------------------------- /static/maps/havana.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/havana.jpg -------------------------------------------------------------------------------- /static/maps/horizon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/horizon.jpg -------------------------------------------------------------------------------- /static/maps/kanezaka.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/kanezaka.jpg -------------------------------------------------------------------------------- /static/maps/midtown.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/midtown.jpg -------------------------------------------------------------------------------- /static/maps/numbani.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/numbani.jpg -------------------------------------------------------------------------------- /static/maps/paraiso.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/paraiso.jpg -------------------------------------------------------------------------------- /static/maps/rialto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/rialto.jpg -------------------------------------------------------------------------------- /static/maps/route-66.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/route-66.jpg -------------------------------------------------------------------------------- /static/maps/runasapi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/runasapi.jpg -------------------------------------------------------------------------------- /static/maps/suravasa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/suravasa.jpg -------------------------------------------------------------------------------- /static/maps/volskaya.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/volskaya.jpg -------------------------------------------------------------------------------- /static/gamemodes/push.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/push.avif -------------------------------------------------------------------------------- /static/maps/ayutthaya.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/ayutthaya.jpg -------------------------------------------------------------------------------- /static/maps/esperanca.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/esperanca.jpg -------------------------------------------------------------------------------- /static/maps/hollywood.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/hollywood.jpg -------------------------------------------------------------------------------- /static/maps/junkertown.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/junkertown.jpg -------------------------------------------------------------------------------- /static/maps/kings-row.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/kings-row.jpg -------------------------------------------------------------------------------- /static/maps/malevento.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/malevento.jpg -------------------------------------------------------------------------------- /static/maps/necropolis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/necropolis.jpg -------------------------------------------------------------------------------- /static/gamemodes/assault.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/assault.avif -------------------------------------------------------------------------------- /static/gamemodes/clash.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/clash.avif -------------------------------------------------------------------------------- /static/gamemodes/control.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/control.avif -------------------------------------------------------------------------------- /static/gamemodes/escort.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/escort.avif -------------------------------------------------------------------------------- /static/gamemodes/hybrid.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/hybrid.avif -------------------------------------------------------------------------------- /static/maps/black-forest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/black-forest.jpg -------------------------------------------------------------------------------- /static/maps/circuit-royal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/circuit-royal.jpg -------------------------------------------------------------------------------- /static/maps/eichenwalde.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/eichenwalde.jpg -------------------------------------------------------------------------------- /static/maps/lijiang-tower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/lijiang-tower.jpg -------------------------------------------------------------------------------- /static/maps/new-junk-city.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/new-junk-city.jpg -------------------------------------------------------------------------------- /static/maps/place-lacroix.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/place-lacroix.jpg -------------------------------------------------------------------------------- /static/maps/redwood-dam.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/redwood-dam.jpg -------------------------------------------------------------------------------- /static/gamemodes/workshop.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/workshop.avif -------------------------------------------------------------------------------- /static/maps/arena-victoriae.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/arena-victoriae.jpg -------------------------------------------------------------------------------- /static/maps/blizzard-world.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/blizzard-world.jpg -------------------------------------------------------------------------------- /static/maps/powder-keg-mine.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/powder-keg-mine.jpg -------------------------------------------------------------------------------- /static/maps/practice-range.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/practice-range.jpg -------------------------------------------------------------------------------- /static/maps/thames-district.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/thames-district.jpg -------------------------------------------------------------------------------- /static/maps/workshop-island.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/workshop-island.jpg -------------------------------------------------------------------------------- /static/gamemodes/deathmatch.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/deathmatch.avif -------------------------------------------------------------------------------- /static/gamemodes/elimination.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/elimination.avif -------------------------------------------------------------------------------- /static/gamemodes/flashpoint.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/flashpoint.avif -------------------------------------------------------------------------------- /static/gamemodes/payload-race.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/payload-race.avif -------------------------------------------------------------------------------- /static/maps/chateau-guillard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/chateau-guillard.jpg -------------------------------------------------------------------------------- /static/maps/new-queen-street.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/new-queen-street.jpg -------------------------------------------------------------------------------- /static/maps/shambali-monastery.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/shambali-monastery.jpg -------------------------------------------------------------------------------- /static/maps/throne-of-anubis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/throne-of-anubis.jpg -------------------------------------------------------------------------------- /static/maps/workshop-chamber.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/workshop-chamber.jpg -------------------------------------------------------------------------------- /static/maps/workshop-expanse.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/workshop-expanse.jpg -------------------------------------------------------------------------------- /static/gamemodes/practice-range.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/practice-range.avif -------------------------------------------------------------------------------- /static/maps/antarctic-peninsula.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/antarctic-peninsula.jpg -------------------------------------------------------------------------------- /static/maps/ecopoint-antarctica.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/ecopoint-antarctica.jpg -------------------------------------------------------------------------------- /static/maps/watchpoint-gibraltar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/watchpoint-gibraltar.jpg -------------------------------------------------------------------------------- /static/gamemodes/capture-the-flag.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/capture-the-flag.avif -------------------------------------------------------------------------------- /static/gamemodes/team-deathmatch.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/gamemodes/team-deathmatch.avif -------------------------------------------------------------------------------- /static/maps/workshop-green-screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/HEAD/static/maps/workshop-green-screen.jpg -------------------------------------------------------------------------------- /app/roles/helpers.py: -------------------------------------------------------------------------------- 1 | def get_role_from_icon_url(url: str) -> str: 2 | """Extracts the role key name from the associated icon URL""" 3 | return url.split("/")[-1].split(".")[0].lower() 4 | -------------------------------------------------------------------------------- /tests/roles/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.helpers import read_html_file 4 | 5 | 6 | @pytest.fixture(scope="package") 7 | def home_html_data(): 8 | return read_html_file("home.html") 9 | -------------------------------------------------------------------------------- /app/roles/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class Role(StrEnum): 5 | """Overwatch heroes roles""" 6 | 7 | DAMAGE = "damage" 8 | SUPPORT = "support" 9 | TANK = "tank" 10 | -------------------------------------------------------------------------------- /tests/maps/parsers/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.maps.parsers.maps_parser import MapsParser 4 | 5 | 6 | @pytest.fixture(scope="package") 7 | def maps_parser() -> MapsParser: 8 | return MapsParser() 9 | -------------------------------------------------------------------------------- /tests/roles/parsers/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.roles.parsers.roles_parser import RolesParser 4 | 5 | 6 | @pytest.fixture(scope="package") 7 | def roles_parser() -> RolesParser: 8 | return RolesParser() 9 | -------------------------------------------------------------------------------- /tests/gamemodes/parsers/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.gamemodes.parsers.gamemodes_parser import GamemodesParser 4 | 5 | 6 | @pytest.fixture(scope="package") 7 | def gamemodes_parser() -> GamemodesParser: 8 | return GamemodesParser() 9 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you found a vulnerability on the project, please use the "Report a security vulnerability" button from the issue creation page (https://github.com/TeKrop/overfast-api/issues/new/choose). 6 | -------------------------------------------------------------------------------- /app/players/exceptions.py: -------------------------------------------------------------------------------- 1 | from app.exceptions import OverfastError 2 | 3 | 4 | class SearchDataRetrievalError(OverfastError): 5 | """Generic search data retrieval Exception (namecards, titles, etc.)""" 6 | 7 | message = "Error while retrieving search data" 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: "uv" 5 | directory: "/" # Location of package manifests 6 | schedule: 7 | interval: "weekly" 8 | groups: 9 | uv-deps: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.14 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: v0.14.0 6 | hooks: 7 | - id: ruff 8 | name: (ruff) Linting and fixing code 9 | args: [--fix, --exit-non-zero-on-fix] 10 | - id: ruff-format 11 | name: (ruff) Formatting code 12 | -------------------------------------------------------------------------------- /app/metaclasses.py: -------------------------------------------------------------------------------- 1 | """Set of metaclasses for the project""" 2 | 3 | from typing import ClassVar 4 | 5 | 6 | class Singleton(type): 7 | """Singleton class, to be used as metaclass.""" 8 | 9 | _instances: ClassVar[dict] = {} 10 | 11 | def __call__(cls, *args, **kwargs): 12 | if cls not in cls._instances: 13 | cls._instances[cls] = super().__call__(*args, **kwargs) 14 | return cls._instances[cls] 15 | -------------------------------------------------------------------------------- /tests/heroes/controllers/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | import pytest 4 | 5 | from app.heroes.controllers.get_hero_controller import GetHeroController 6 | 7 | 8 | @pytest.fixture(scope="package") 9 | def get_hero_controller() -> GetHeroController: 10 | with patch( 11 | "app.controllers.AbstractController.__init__", MagicMock(return_value=None) 12 | ): 13 | return GetHeroController() 14 | -------------------------------------------------------------------------------- /app/maps/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | from app.helpers import read_csv_data_file 4 | 5 | # Dynamically create the MapKey enum by using the CSV File 6 | maps_data = read_csv_data_file("maps") 7 | MapKey = StrEnum( 8 | "MapKey", 9 | { 10 | map_data["key"].upper().replace("-", "_"): map_data["key"] 11 | for map_data in maps_data 12 | }, 13 | ) 14 | MapKey.__doc__ = "Map keys used to identify Overwatch maps in general" 15 | -------------------------------------------------------------------------------- /app/gamemodes/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | from app.helpers import read_csv_data_file 4 | 5 | # Dynamically create the MapGamemode enum by using the CSV File 6 | gamemodes_data = read_csv_data_file("gamemodes") 7 | MapGamemode = StrEnum( 8 | "MapGamemode", 9 | { 10 | gamemode["key"].upper().replace("-", "_"): gamemode["key"] 11 | for gamemode in gamemodes_data 12 | }, 13 | ) 14 | MapGamemode.__doc__ = "Maps gamemodes keys" 15 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app import helpers 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("input_duration", "result"), 8 | [ 9 | (98760, "1 day, 3 hours, 26 minutes"), 10 | (86400, "1 day"), 11 | (7200, "2 hours"), 12 | (3600, "1 hour"), 13 | (600, "10 minutes"), 14 | (60, "1 minute"), 15 | ], 16 | ) 17 | def test_get_human_readable_duration(input_duration: int, result: str): 18 | assert helpers.get_human_readable_duration(input_duration) == result 19 | -------------------------------------------------------------------------------- /app/players/controllers/search_players_controller.py: -------------------------------------------------------------------------------- 1 | """Search Players Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.player_search_parser import PlayerSearchParser 9 | 10 | 11 | class SearchPlayersController(AbstractController): 12 | """Search Players Controller used in order to find an Overwatch player""" 13 | 14 | parser_classes: ClassVar[list] = [PlayerSearchParser] 15 | timeout = settings.search_account_path_cache_timeout 16 | -------------------------------------------------------------------------------- /app/roles/controllers/list_roles_controller.py: -------------------------------------------------------------------------------- 1 | """List Roles Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.roles_parser import RolesParser 9 | 10 | 11 | class ListRolesController(AbstractController): 12 | """List Roles Controller used in order to 13 | retrieve a list of available Overwatch roles. 14 | """ 15 | 16 | parser_classes: ClassVar[list[type]] = [RolesParser] 17 | timeout = settings.heroes_path_cache_timeout 18 | -------------------------------------------------------------------------------- /app/heroes/controllers/list_heroes_controller.py: -------------------------------------------------------------------------------- 1 | """List Heroes Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.heroes_parser import HeroesParser 9 | 10 | 11 | class ListHeroesController(AbstractController): 12 | """List Heroes Controller used in order to 13 | retrieve a list of available Overwatch heroes. 14 | """ 15 | 16 | parser_classes: ClassVar[list[type]] = [HeroesParser] 17 | timeout = settings.heroes_path_cache_timeout 18 | -------------------------------------------------------------------------------- /app/maps/controllers/list_maps_controller.py: -------------------------------------------------------------------------------- 1 | """List Maps Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.maps_parser import MapsParser 9 | 10 | 11 | class ListMapsController(AbstractController): 12 | """List Maps Controller used in order to retrieve a list of 13 | available Overwatch maps, using the MapsParser class. 14 | """ 15 | 16 | parser_classes: ClassVar[list[type]] = [MapsParser] 17 | timeout = settings.csv_cache_timeout 18 | -------------------------------------------------------------------------------- /app/gamemodes/controllers/list_gamemodes_controller.py: -------------------------------------------------------------------------------- 1 | """List Gamemodes Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | from app.gamemodes.parsers.gamemodes_parser import GamemodesParser 8 | 9 | 10 | class ListGamemodesController(AbstractController): 11 | """List Gamemodes Controller used in order to retrieve a list of 12 | available Overwatch gamemodes, using the GamemodesParser class. 13 | """ 14 | 15 | parser_classes: ClassVar[list[type]] = [GamemodesParser] 16 | timeout = settings.csv_cache_timeout 17 | -------------------------------------------------------------------------------- /app/heroes/controllers/get_hero_stats_summary_controller.py: -------------------------------------------------------------------------------- 1 | """List Heroes Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.hero_stats_summary_parser import HeroStatsSummaryParser 9 | 10 | 11 | class GetHeroStatsSummaryController(AbstractController): 12 | """Get Hero Stats Summary Controller used in order to 13 | retrieve usage statistics for Overwatch heroes. 14 | """ 15 | 16 | parser_classes: ClassVar[list[type]] = [HeroStatsSummaryParser] 17 | timeout = settings.hero_stats_cache_timeout 18 | -------------------------------------------------------------------------------- /app/heroes/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | from app.helpers import read_csv_data_file 4 | 5 | 6 | class MediaType(StrEnum): 7 | """Media types for heroes pages""" 8 | 9 | COMIC = "comic" 10 | SHORT_STORY = "short-story" 11 | VIDEO = "video" 12 | 13 | 14 | # Dynamically create the HeroKey enum by using the CSV File 15 | heroes_data = read_csv_data_file("heroes") 16 | HeroKey = StrEnum( 17 | "HeroKey", 18 | { 19 | hero_data["key"].upper().replace("-", "_"): hero_data["key"] 20 | for hero_data in heroes_data 21 | }, 22 | ) 23 | HeroKey.__doc__ = "Hero keys used to identify Overwatch heroes in general" 24 | -------------------------------------------------------------------------------- /app/players/controllers/get_player_career_stats_controller.py: -------------------------------------------------------------------------------- 1 | """Player Stats Summary Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.player_career_stats_parser import PlayerCareerStatsParser 9 | 10 | 11 | class GetPlayerCareerStatsController(AbstractController): 12 | """Player Career Stats Controller used in order to retrieve career 13 | statistics of a player without labels, easily explorable 14 | """ 15 | 16 | parser_classes: ClassVar[list] = [PlayerCareerStatsParser] 17 | timeout = settings.career_path_cache_timeout 18 | -------------------------------------------------------------------------------- /app/players/controllers/get_player_career_controller.py: -------------------------------------------------------------------------------- 1 | """Player Career Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.player_career_parser import PlayerCareerParser 9 | 10 | 11 | class GetPlayerCareerController(AbstractController): 12 | """Player Career Controller used in order to retrieve data about a player 13 | Overwatch career : summary, statistics about heroes, etc. using the 14 | PlayerCareerParser class. 15 | """ 16 | 17 | parser_classes: ClassVar[list] = [PlayerCareerParser] 18 | timeout = settings.career_path_cache_timeout 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue or a bug you encountered with the API 4 | title: '' 5 | labels: bug 6 | assignees: TeKrop 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | If applicable, steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: TeKrop 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /app/players/controllers/get_player_stats_summary_controller.py: -------------------------------------------------------------------------------- 1 | """Player Stats Summary Controller module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.player_stats_summary_parser import PlayerStatsSummaryParser 9 | 10 | 11 | class GetPlayerStatsSummaryController(AbstractController): 12 | """Player Stats Summary Controller used in order to retrieve essential 13 | stats of a player, often used for tracking progress : winrate, kda, damage, etc. 14 | Using the PlayerStatsSummaryParser. 15 | """ 16 | 17 | parser_classes: ClassVar[list] = [PlayerStatsSummaryParser] 18 | timeout = settings.career_path_cache_timeout 19 | -------------------------------------------------------------------------------- /tests/test_documentation_route.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import TYPE_CHECKING 3 | 4 | from fastapi import status 5 | 6 | if TYPE_CHECKING: 7 | from fastapi.testclient import TestClient 8 | 9 | 10 | def test_get_redoc_documentation(client: TestClient): 11 | response = client.get("/") 12 | assert response.status_code == status.HTTP_200_OK 13 | assert ( 14 | re.search("(.*)", response.text, re.IGNORECASE)[1] 15 | == "OverFast API - Documentation" 16 | ) 17 | 18 | 19 | def test_get_swagger_documentation(client: TestClient): 20 | response = client.get("/docs") 21 | assert response.status_code == status.HTTP_200_OK 22 | assert ( 23 | re.search("(.*)", response.text, re.IGNORECASE)[1] 24 | == "OverFast API - Documentation" 25 | ) 26 | -------------------------------------------------------------------------------- /app/heroes/parsers/heroes_stats_parser.py: -------------------------------------------------------------------------------- 1 | """Heroes Stats Parser module""" 2 | 3 | from typing import ClassVar 4 | 5 | from app.parsers import CSVParser 6 | 7 | 8 | class HeroesStatsParser(CSVParser): 9 | """Heroes stats (health, armor, shields) Parser class""" 10 | 11 | filename = "heroes" 12 | hitpoints_keys: ClassVar[set[str]] = {"health", "armor", "shields"} 13 | 14 | def parse_data(self) -> dict: 15 | return { 16 | hero_stats["key"]: {"hitpoints": self.__get_hitpoints(hero_stats)} 17 | for hero_stats in self.csv_data 18 | } 19 | 20 | def __get_hitpoints(self, hero_stats: dict) -> dict: 21 | hitpoints = {hp_key: int(hero_stats[hp_key]) for hp_key in self.hitpoints_keys} 22 | hitpoints["total"] = sum(hitpoints.values()) 23 | return hitpoints 24 | -------------------------------------------------------------------------------- /tests/maps/parsers/test_maps_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | 5 | from app.exceptions import OverfastError 6 | 7 | if TYPE_CHECKING: 8 | from app.maps.parsers.maps_parser import MapsParser 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_maps_page_parsing(maps_parser: MapsParser): 13 | try: 14 | await maps_parser.parse() 15 | except OverfastError: 16 | pytest.fail("Maps list parsing failed") 17 | 18 | # Just check the format of the first map in the list 19 | assert maps_parser.data[0] == { 20 | "key": "aatlis", 21 | "name": "Aatlis", 22 | "screenshot": "https://overfast-api.tekrop.fr/static/maps/aatlis.jpg", 23 | "gamemodes": ["flashpoint"], 24 | "location": "Morocco", 25 | "country_code": "MA", 26 | } 27 | -------------------------------------------------------------------------------- /tests/gamemodes/test_gamemodes_route.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING 3 | 4 | from fastapi import status 5 | 6 | from app.config import settings 7 | 8 | if TYPE_CHECKING: 9 | from fastapi.testclient import TestClient 10 | 11 | 12 | def test_get_gamemodes(client: TestClient): 13 | response = client.get("/gamemodes") 14 | assert response.status_code == status.HTTP_200_OK 15 | 16 | gamemodes = response.json() 17 | assert len(gamemodes) > 0, "No gamemodes returned" 18 | 19 | for gamemode in gamemodes: 20 | for image_key in ("icon", "screenshot"): 21 | image_url = gamemode[image_key] 22 | image_path = image_url.removeprefix(f"{settings.app_base_url}/") 23 | path = Path(image_path) 24 | assert path.is_file(), f"{image_key} file does not exist: {path}" 25 | -------------------------------------------------------------------------------- /tests/roles/parsers/test_roles_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.exceptions import OverfastError 8 | from app.roles.enums import Role 9 | 10 | if TYPE_CHECKING: 11 | from app.roles.parsers.roles_parser import RolesParser 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_roles_page_parsing(roles_parser: RolesParser, home_html_data: str): 16 | with patch( 17 | "httpx.AsyncClient.get", 18 | return_value=Mock(status_code=status.HTTP_200_OK, text=home_html_data), 19 | ): 20 | try: 21 | await roles_parser.parse() 22 | except OverfastError: 23 | pytest.fail("Roles list parsing failed") 24 | 25 | assert {role["key"] for role in roles_parser.data} == {r.value for r in Role} 26 | -------------------------------------------------------------------------------- /tests/heroes/parsers/test_heroes_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.exceptions import OverfastError 8 | from app.heroes.enums import HeroKey 9 | 10 | if TYPE_CHECKING: 11 | from app.heroes.parsers.heroes_parser import HeroesParser 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_heroes_page_parsing(heroes_parser: HeroesParser, heroes_html_data: str): 16 | with patch( 17 | "httpx.AsyncClient.get", 18 | return_value=Mock(status_code=status.HTTP_200_OK, text=heroes_html_data), 19 | ): 20 | try: 21 | await heroes_parser.parse() 22 | except OverfastError: 23 | pytest.fail("Heroes list parsing failed") 24 | 25 | assert all(hero["key"] in iter(HeroKey) for hero in heroes_parser.data) 26 | -------------------------------------------------------------------------------- /tests/heroes/parsers/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.heroes.parsers.hero_parser import HeroParser 4 | from app.heroes.parsers.hero_stats_summary_parser import HeroStatsSummaryParser 5 | from app.heroes.parsers.heroes_parser import HeroesParser 6 | from app.players.enums import PlayerGamemode, PlayerPlatform, PlayerRegion 7 | 8 | 9 | @pytest.fixture(scope="package") 10 | def hero_parser() -> HeroParser: 11 | return HeroParser() 12 | 13 | 14 | @pytest.fixture(scope="package") 15 | def heroes_parser() -> HeroesParser: 16 | return HeroesParser() 17 | 18 | 19 | @pytest.fixture(scope="package") 20 | def hero_stats_summary_parser() -> HeroStatsSummaryParser: 21 | return HeroStatsSummaryParser( 22 | platform=PlayerPlatform.PC, 23 | gamemode=PlayerGamemode.COMPETITIVE, 24 | region=PlayerRegion.EUROPE, 25 | order_by="hero:asc", 26 | ) 27 | -------------------------------------------------------------------------------- /app/gamemodes/parsers/gamemodes_parser.py: -------------------------------------------------------------------------------- 1 | """Gamemodes Parser module""" 2 | 3 | from app.parsers import CSVParser 4 | 5 | 6 | class GamemodesParser(CSVParser): 7 | """Overwatch map gamemodes list page Parser class""" 8 | 9 | filename = "gamemodes" 10 | 11 | def parse_data(self) -> list[dict]: 12 | return [ 13 | { 14 | "key": gamemode["key"], 15 | "name": gamemode["name"], 16 | "icon": self.get_static_url( 17 | f"{gamemode['key']}-icon", 18 | extension="svg", 19 | ), 20 | "description": gamemode["description"], 21 | "screenshot": self.get_static_url( 22 | gamemode["key"], 23 | extension="avif", 24 | ), 25 | } 26 | for gamemode in self.csv_data 27 | ] 28 | -------------------------------------------------------------------------------- /app/roles/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, HttpUrl 2 | 3 | from .enums import Role 4 | 5 | 6 | class RoleDetail(BaseModel): 7 | key: Role = Field(..., description="Key name of the role", examples=["damage"]) 8 | name: str = Field(..., description="Name of the role", examples=["Damage"]) 9 | icon: HttpUrl = Field( 10 | ..., 11 | description="Icon URL of the role", 12 | examples=[ 13 | "https://blz-contentstack-images.akamaized.net/v3/assets/blt9c12f249ac15c7ec/bltc1d840ba007f88a8/62ea89572fdd1011027e605d/Damage.svg", 14 | ], 15 | ) 16 | description: str = Field( 17 | ..., 18 | description="Description of the role", 19 | examples=[ 20 | "Damage heroes seek out, engage, and obliterate the enemy with wide-ranging tools, abilities, and play styles. Fearsome but fragile, these heroes require backup to survive.", 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # Container settings 2 | APP_VOLUME_PATH= 3 | APP_PORT=80 4 | 5 | # Application settings 6 | APP_BASE_URL=https://overfast-api.tekrop.fr 7 | LOG_LEVEL=info 8 | STATUS_PAGE_URL= 9 | PROFILER= 10 | NEW_ROUTE_PATH= 11 | 12 | # Rate limiting 13 | RETRY_AFTER_HEADER=Retry-After 14 | BLIZZARD_RATE_LIMIT_RETRY_AFTER=5 15 | RATE_LIMIT_PER_SECOND_PER_IP=30 16 | RATE_LIMIT_PER_IP_BURST=5 17 | MAX_CONNECTIONS_PER_IP=10 18 | 19 | # Valkey 20 | VALKEY_HOST=valkey 21 | VALKEY_PORT=6379 22 | VALKEY_MEMORY_LIMIT=1gb 23 | 24 | # Cache configuration 25 | CACHE_TTL_HEADER=X-Cache-TTL 26 | PLAYER_CACHE_TIMEOUT=86400 27 | HEROES_PATH_CACHE_TIMEOUT=86400 28 | HERO_PATH_CACHE_TIMEOUT=86400 29 | CSV_CACHE_TIMEOUT=86400 30 | CAREER_PATH_CACHE_TIMEOUT=600 31 | SEARCH_ACCOUNT_PATH_CACHE_TIMEOUT=600 32 | HERO_STATS_CACHE_TIMEOUT=3600 33 | 34 | # Critical error Discord webhook 35 | DISCORD_WEBHOOK_ENABLED=false 36 | DISCORD_WEBHOOK_URL="" 37 | DISCORD_MESSAGE_ON_RATE_LIMIT=false -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import fakeredis 4 | import pytest 5 | from fastapi.testclient import TestClient 6 | 7 | from app.main import app 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def client() -> TestClient: 12 | return TestClient(app) 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def valkey_server(): 17 | return fakeredis.FakeValkey(protocol=3) 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def _patch_before_every_test(valkey_server: fakeredis.FakeValkey): 22 | # Flush Valkey before and after every tests 23 | valkey_server.flushdb() 24 | 25 | with ( 26 | patch("app.helpers.settings.discord_webhook_enabled", False), 27 | patch("app.helpers.settings.profiler", None), 28 | patch( 29 | "app.cache_manager.CacheManager.valkey_server", 30 | valkey_server, 31 | ), 32 | ): 33 | yield 34 | 35 | valkey_server.flushdb() 36 | -------------------------------------------------------------------------------- /tests/players/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from fastapi import status 7 | 8 | from tests.helpers import read_html_file, read_json_file 9 | 10 | if TYPE_CHECKING: 11 | from _pytest.fixtures import SubRequest 12 | 13 | 14 | @pytest.fixture(scope="package") 15 | def player_html_data(request: SubRequest) -> str: 16 | return read_html_file(f"players/{request.param}.html") 17 | 18 | 19 | @pytest.fixture(scope="package") 20 | def search_players_blizzard_json_data() -> list[dict]: 21 | return read_json_file("search_players_blizzard_result.json") 22 | 23 | 24 | @pytest.fixture(scope="package") 25 | def player_search_response_mock(search_players_blizzard_json_data: list[dict]) -> Mock: 26 | return Mock( 27 | status_code=status.HTTP_200_OK, 28 | text=json.dumps(search_players_blizzard_json_data), 29 | json=lambda: search_players_blizzard_json_data, 30 | ) 31 | -------------------------------------------------------------------------------- /tests/players/parsers/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | 5 | from app.players.parsers.player_career_parser import PlayerCareerParser 6 | from app.players.parsers.player_career_stats_parser import PlayerCareerStatsParser 7 | from app.players.parsers.player_stats_summary_parser import PlayerStatsSummaryParser 8 | 9 | if TYPE_CHECKING: 10 | from _pytest.fixtures import SubRequest 11 | 12 | 13 | @pytest.fixture(scope="package") 14 | def player_career_parser(request: SubRequest) -> PlayerCareerParser: 15 | return PlayerCareerParser(player_id=request.param) 16 | 17 | 18 | @pytest.fixture(scope="package") 19 | def player_stats_summary_parser(request: SubRequest) -> PlayerStatsSummaryParser: 20 | return PlayerStatsSummaryParser(player_id=request.param) 21 | 22 | 23 | @pytest.fixture(scope="package") 24 | def player_career_stats_parser(request: SubRequest) -> PlayerCareerStatsParser: 25 | return PlayerCareerStatsParser(player_id=request.param) 26 | -------------------------------------------------------------------------------- /app/gamemodes/router.py: -------------------------------------------------------------------------------- 1 | """Gamemodes endpoints router : gamemodes list, etc.""" 2 | 3 | from fastapi import APIRouter, Request, Response 4 | 5 | from app.enums import RouteTag 6 | from app.helpers import success_responses 7 | 8 | from .controllers.list_gamemodes_controller import ListGamemodesController 9 | from .models import GamemodeDetails 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get( 15 | "", 16 | responses=success_responses, 17 | tags=[RouteTag.GAMEMODES], 18 | summary="Get a list of gamemodes", 19 | description=( 20 | "Get a list of Overwatch gamemodes : Assault, Escort, Flashpoint, Hybrid, etc." 21 | f"
**Cache TTL : {ListGamemodesController.get_human_readable_timeout()}.**" 22 | ), 23 | operation_id="list_map_gamemodes", 24 | ) 25 | async def list_map_gamemodes( 26 | request: Request, response: Response 27 | ) -> list[GamemodeDetails]: 28 | return await ListGamemodesController(request, response).process_request() 29 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | """Set of pydantic models describing errors returned by the API""" 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from app.config import settings 6 | 7 | 8 | class BlizzardErrorMessage(BaseModel): 9 | error: str = Field( 10 | ..., 11 | description="Message describing the error", 12 | examples=["Couldn't get Blizzard page (HTTP 503 error) : Service Unavailable"], 13 | ) 14 | 15 | 16 | class InternalServerErrorMessage(BaseModel): 17 | error: str = Field( 18 | ..., 19 | description="Message describing the internal server error", 20 | examples=[settings.internal_server_error_message], 21 | ) 22 | 23 | 24 | class RateLimitErrorMessage(BaseModel): 25 | error: str = Field( 26 | ..., 27 | description="Message describing the rate limit error and number of seconds before retrying", 28 | examples=[ 29 | "API has been rate limited by Blizzard, please wait for 5 seconds before retrying" 30 | ], 31 | ) 32 | -------------------------------------------------------------------------------- /tests/heroes/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from fastapi import status 7 | 8 | from tests.helpers import read_html_file, read_json_file 9 | 10 | if TYPE_CHECKING: 11 | from _pytest.fixtures import SubRequest 12 | 13 | 14 | @pytest.fixture(scope="package") 15 | def heroes_html_data(): 16 | return read_html_file("heroes.html") 17 | 18 | 19 | @pytest.fixture(scope="package") 20 | def hero_html_data(request: SubRequest): 21 | return read_html_file(f"heroes/{request.param}.html") 22 | 23 | 24 | @pytest.fixture(scope="package") 25 | def hero_stats_json_data() -> list[dict]: 26 | return read_json_file("blizzard_hero_stats.json") 27 | 28 | 29 | @pytest.fixture(scope="package") 30 | def hero_stats_response_mock(hero_stats_json_data: list[dict]) -> Mock: 31 | return Mock( 32 | status_code=status.HTTP_200_OK, 33 | text=json.dumps(hero_stats_json_data), 34 | json=lambda: hero_stats_json_data, 35 | ) 36 | -------------------------------------------------------------------------------- /tests/gamemodes/parsers/test_gamemodes_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | 5 | from app.exceptions import OverfastError 6 | 7 | if TYPE_CHECKING: 8 | from app.gamemodes.parsers.gamemodes_parser import GamemodesParser 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_gamemodes_page_parsing(gamemodes_parser: GamemodesParser): 13 | try: 14 | await gamemodes_parser.parse() 15 | except OverfastError: 16 | pytest.fail("Game modes list parsing failed") 17 | 18 | # Just check the format of the first gamemode in the list 19 | assert gamemodes_parser.data[0] == { 20 | "key": "assault", 21 | "name": "Assault", 22 | "icon": "https://overfast-api.tekrop.fr/static/gamemodes/assault-icon.svg", 23 | "description": "Teams fight to capture or defend two successive points against the enemy team. It's an inactive Overwatch 1 gamemode, also called 2CP.", 24 | "screenshot": "https://overfast-api.tekrop.fr/static/gamemodes/assault.avif", 25 | } 26 | -------------------------------------------------------------------------------- /app/enums.py: -------------------------------------------------------------------------------- 1 | """Set of enumerations of generic data displayed in the API : 2 | heroes, gamemodes, etc. 3 | 4 | """ 5 | 6 | from enum import StrEnum 7 | 8 | 9 | class RouteTag(StrEnum): 10 | """Tags used to classify API routes""" 11 | 12 | HEROES = "🦸 Heroes" 13 | GAMEMODES = "🎲 Gamemodes" 14 | MAPS = "🗺️ Maps" 15 | PLAYERS = "🎮 Players" 16 | 17 | 18 | class Locale(StrEnum): 19 | """Locales supported by Blizzard""" 20 | 21 | GERMAN = "de-de" 22 | ENGLISH_EU = "en-gb" 23 | ENGLISH_US = "en-us" 24 | SPANISH_EU = "es-es" 25 | SPANISH_LATIN = "es-mx" 26 | FRENCH = "fr-fr" 27 | ITALIANO = "it-it" 28 | JAPANESE = "ja-jp" 29 | KOREAN = "ko-kr" 30 | POLISH = "pl-pl" 31 | PORTUGUESE_BRAZIL = "pt-br" 32 | RUSSIAN = "ru-ru" 33 | CHINESE_TAIWAN = "zh-tw" 34 | 35 | 36 | class Profiler(StrEnum): 37 | """Supported profilers list""" 38 | 39 | MEMRAY = "memray" 40 | PYINSTRUMENT = "pyinstrument" 41 | TRACEMALLOC = "tracemalloc" 42 | OBJGRAPH = "objgraph" 43 | -------------------------------------------------------------------------------- /app/roles/router.py: -------------------------------------------------------------------------------- 1 | """Roles endpoints router : roles list, etc.""" 2 | 3 | from typing import Annotated 4 | 5 | from fastapi import APIRouter, Query, Request, Response 6 | 7 | from app.enums import Locale, RouteTag 8 | from app.helpers import routes_responses 9 | 10 | from .controllers.list_roles_controller import ListRolesController 11 | from .models import RoleDetail 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get( 17 | "", 18 | responses=routes_responses, 19 | tags=[RouteTag.HEROES], 20 | summary="Get a list of roles", 21 | description=( 22 | "Get a list of available Overwatch roles." 23 | f"
**Cache TTL : {ListRolesController.get_human_readable_timeout()}.**" 24 | ), 25 | operation_id="list_roles", 26 | ) 27 | async def list_roles( 28 | request: Request, 29 | response: Response, 30 | locale: Annotated[ 31 | Locale, Query(title="Locale to be displayed") 32 | ] = Locale.ENGLISH_US, 33 | ) -> list[RoleDetail]: 34 | return await ListRolesController(request, response).process_request(locale=locale) 35 | -------------------------------------------------------------------------------- /app/maps/parsers/maps_parser.py: -------------------------------------------------------------------------------- 1 | """Maps Parser module""" 2 | 3 | from app.parsers import CSVParser 4 | 5 | 6 | class MapsParser(CSVParser): 7 | """Overwatch maps list page Parser class""" 8 | 9 | filename = "maps" 10 | 11 | def parse_data(self) -> list[dict]: 12 | return [ 13 | { 14 | "key": map_dict["key"], 15 | "name": map_dict["name"], 16 | "screenshot": self.get_static_url(map_dict["key"]), 17 | "gamemodes": map_dict["gamemodes"].split(","), 18 | "location": map_dict["location"], 19 | "country_code": map_dict.get("country_code") or None, 20 | } 21 | for map_dict in self.csv_data 22 | ] 23 | 24 | def filter_request_using_query(self, **kwargs) -> list: 25 | gamemode = kwargs.get("gamemode") 26 | return ( 27 | self.data 28 | if not gamemode 29 | else [ 30 | map_dict for map_dict in self.data if gamemode in map_dict["gamemodes"] 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build arguments 2 | ARG PYTHON_VERSION=3.14 3 | ARG UV_VERSION=0.9.2 4 | 5 | # Create a temporary stage to pull the uv binary 6 | FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv-stage 7 | 8 | # Main stage 9 | FROM python:${PYTHON_VERSION}-alpine AS main 10 | 11 | # Copy the uv binary from the temporary stage to the main stage 12 | COPY --from=uv-stage /uv /bin/uv 13 | 14 | # Copy only requirements (caching in Docker layer) 15 | COPY pyproject.toml uv.lock /code/ 16 | 17 | # Sync the project into a new environment (no dev dependencies) 18 | WORKDIR /code 19 | 20 | # Install the project 21 | RUN apk add --no-cache build-base \ 22 | && uv sync --frozen --no-cache --no-dev \ 23 | && apk del build-base 24 | 25 | # Copy code and static folders 26 | COPY ./app /code/app 27 | COPY ./static /code/static 28 | 29 | # Copy crontabs file and make it executable 30 | COPY ./build/overfast-crontab /etc/crontabs/root 31 | RUN chmod +x /etc/crontabs/root 32 | 33 | # For dev image, copy the tests and install necessary dependencies 34 | FROM main AS dev 35 | RUN uv sync --frozen --no-cache 36 | COPY ./tests /code/tests 37 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from app.config import settings 5 | 6 | 7 | def read_html_file(filepath: str) -> str | None: 8 | """Helper method for retrieving fixture HTML file data""" 9 | html_file_object = Path(f"{settings.test_fixtures_root_path}/html/{filepath}") 10 | if not html_file_object.is_file(): 11 | return None # pragma: no cover 12 | 13 | with html_file_object.open(encoding="utf-8") as html_file: 14 | return html_file.read() 15 | 16 | 17 | def read_json_file(filepath: str) -> dict | list | None: 18 | """Helper method for retrieving fixture JSON file data""" 19 | with Path(f"{settings.test_fixtures_root_path}/json/{filepath}").open( 20 | encoding="utf-8", 21 | ) as json_file: 22 | return json.load(json_file) 23 | 24 | 25 | # List of players used for testing 26 | players_ids = [ 27 | "KIRIKO-12460", # Console player 28 | "TeKrop-2217", # Classic profile 29 | "JohnV1-1190", # Player without any title ingame 30 | ] 31 | 32 | # Non-existent player ID used for testing 33 | unknown_player_id = "Unknown-1234" 34 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | """Set of custom exceptions used in the API""" 2 | 3 | from fastapi import status 4 | 5 | 6 | class OverfastError(Exception): 7 | """Generic OverFast API Exception""" 8 | 9 | message = "OverFast API Error" 10 | 11 | def __str__(self): 12 | return self.message 13 | 14 | 15 | class ParserBlizzardError(OverfastError): 16 | """Exception raised when there was an error in a Parser class 17 | initialization, usually when the data is not available 18 | """ 19 | 20 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR 21 | message = "Parser Blizzard Error" 22 | 23 | def __init__(self, status_code: int, message: str): 24 | super().__init__() 25 | self.status_code = status_code 26 | self.message = message 27 | 28 | 29 | class ParserParsingError(OverfastError): 30 | """Exception raised when there was an error during data parsing""" 31 | 32 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR 33 | message = "Parser Parsing Error" 34 | 35 | def __init__(self, message: str): 36 | super().__init__() 37 | self.message = message 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2025 Valentin Porchet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /static/gamemodes/assault-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/maps/models.py: -------------------------------------------------------------------------------- 1 | """Set of pydantic models used for Maps API routes""" 2 | 3 | from pydantic import BaseModel, Field, HttpUrl 4 | 5 | from app.gamemodes.enums import MapGamemode 6 | 7 | from .enums import MapKey 8 | 9 | 10 | class Map(BaseModel): 11 | key: MapKey = Field( 12 | ..., 13 | description="Key name of the map", 14 | examples=["aatlis"], 15 | ) 16 | name: str = Field(..., description="Name of the map", examples=["Aatlis"]) 17 | screenshot: HttpUrl = Field( 18 | ..., 19 | description="Screenshot of the map", 20 | examples=["https://overfast-api.tekrop.fr/static/maps/aatlis.jpg"], 21 | ) 22 | gamemodes: list[MapGamemode] = Field( 23 | ..., 24 | description="Main gamemodes on which the map is playable", 25 | ) 26 | location: str = Field( 27 | ..., 28 | description="Location of the map", 29 | examples=["Morocco"], 30 | ) 31 | country_code: str | None = Field( 32 | ..., 33 | min_length=2, 34 | max_length=2, 35 | description=( 36 | "Country Code of the location of the map. If not defined, it's null." 37 | ), 38 | examples=["MA"], 39 | ) 40 | -------------------------------------------------------------------------------- /app/heroes/parsers/heroes_parser.py: -------------------------------------------------------------------------------- 1 | """Heroes page Parser module""" 2 | 3 | from app.config import settings 4 | from app.parsers import HTMLParser 5 | 6 | 7 | class HeroesParser(HTMLParser): 8 | """Overwatch heroes list page Parser class""" 9 | 10 | root_path = settings.heroes_path 11 | 12 | async def parse_data(self) -> list[dict]: 13 | return sorted( 14 | [ 15 | { 16 | "key": hero.attributes["href"].split("/")[-1], 17 | "name": hero.css_first("blz-card blz-content-block h2").text(), 18 | "portrait": hero.css_first("blz-card blz-image").attributes["src"], 19 | "role": hero.attributes["data-role"], 20 | } 21 | for hero in self.root_tag.css( 22 | "div.heroIndexWrapper blz-media-gallery a" 23 | ) 24 | ], 25 | key=lambda hero: hero["key"], 26 | ) 27 | 28 | def filter_request_using_query(self, **kwargs) -> list[dict]: 29 | role = kwargs.get("role") 30 | return ( 31 | self.data 32 | if not role 33 | else [hero_dict for hero_dict in self.data if hero_dict["role"] == role] 34 | ) 35 | -------------------------------------------------------------------------------- /app/maps/router.py: -------------------------------------------------------------------------------- 1 | """Maps endpoints router : maps list, etc.""" 2 | 3 | from typing import Annotated 4 | 5 | from fastapi import APIRouter, Query, Request, Response 6 | 7 | from app.enums import RouteTag 8 | from app.gamemodes.enums import MapGamemode 9 | from app.helpers import success_responses 10 | 11 | from .controllers.list_maps_controller import ListMapsController 12 | from .models import Map 13 | 14 | router = APIRouter() 15 | 16 | 17 | @router.get( 18 | "", 19 | responses=success_responses, 20 | tags=[RouteTag.MAPS], 21 | summary="Get a list of maps", 22 | description=( 23 | "Get a list of Overwatch maps : Hanamura, King's Row, Dorado, etc." 24 | f"
**Cache TTL : {ListMapsController.get_human_readable_timeout()}.**" 25 | ), 26 | operation_id="list_maps", 27 | ) 28 | async def list_maps( 29 | request: Request, 30 | response: Response, 31 | gamemode: Annotated[ 32 | MapGamemode | None, 33 | Query( 34 | title="Gamemode filter", 35 | description="Filter maps available for a specific gamemode", 36 | ), 37 | ] = None, 38 | ) -> list[Map]: 39 | return await ListMapsController(request, response).process_request( 40 | gamemode=gamemode 41 | ) 42 | -------------------------------------------------------------------------------- /app/gamemodes/models.py: -------------------------------------------------------------------------------- 1 | """Set of pydantic models used for Gamemodes API routes""" 2 | 3 | from pydantic import BaseModel, Field, HttpUrl 4 | 5 | from .enums import MapGamemode 6 | 7 | 8 | class GamemodeDetails(BaseModel): 9 | key: MapGamemode = Field( 10 | ..., 11 | description=( 12 | "Key corresponding to the gamemode. Can be " 13 | "used as filter on the maps endpoint." 14 | ), 15 | examples=["push"], 16 | ) 17 | name: str = Field(..., description="Name of the gamemode", examples=["Push"]) 18 | icon: HttpUrl = Field( 19 | ..., 20 | description="Icon URL of the gamemode", 21 | examples=[ 22 | "https://overfast-api.tekrop.fr/static/gamemodes/push-icon.svg", 23 | ], 24 | ) 25 | description: str = Field( 26 | ..., 27 | description="Description of the gamemode", 28 | examples=[ 29 | "Teams battle to take control of a robot and push it toward the enemy base.", 30 | ], 31 | ) 32 | screenshot: HttpUrl = Field( 33 | ..., 34 | description="URL of an example screenshot of a map for the gamemode", 35 | examples=[ 36 | "https://overfast-api.tekrop.fr/static/gamemodes/push.avif", 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /static/gamemodes/capture-the-flag-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/roles/parsers/roles_parser.py: -------------------------------------------------------------------------------- 1 | """Roles Parser module""" 2 | 3 | from app.config import settings 4 | from app.parsers import HTMLParser 5 | 6 | from ..helpers import get_role_from_icon_url 7 | 8 | 9 | class RolesParser(HTMLParser): 10 | """Overwatch map gamemodes list page Parser class""" 11 | 12 | root_path = settings.home_path 13 | 14 | async def parse_data(self) -> list[dict]: 15 | roles_container = self.root_tag.css_first( 16 | "div.homepage-features-heroes blz-feature-carousel-section" 17 | ) 18 | 19 | roles_icons = [ 20 | role_icon_div.css_first("blz-image").attributes["src"] 21 | for role_icon_div in roles_container.css_first("blz-tab-controls").css( 22 | "blz-tab-control" 23 | ) 24 | ] 25 | 26 | return [ 27 | { 28 | "key": get_role_from_icon_url(roles_icons[role_index]), 29 | "name": role_div.css_first("blz-header h3").text().capitalize(), 30 | "icon": roles_icons[role_index], 31 | "description": (role_div.css_first("blz-header div").text().strip()), 32 | } 33 | for role_index, role_div in list( 34 | enumerate(roles_container.css("blz-feature")) 35 | )[:3] 36 | ] 37 | -------------------------------------------------------------------------------- /static/gamemodes/clash-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/maps/test_maps_route.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.config import settings 8 | from app.gamemodes.enums import MapGamemode 9 | 10 | if TYPE_CHECKING: 11 | from fastapi.testclient import TestClient 12 | 13 | 14 | def test_get_maps(client: TestClient): 15 | response = client.get("/maps") 16 | assert response.status_code == status.HTTP_200_OK 17 | 18 | maps = response.json() 19 | assert len(maps) > 0, "No maps returned" 20 | 21 | for map_data in maps: 22 | screenshot_url = map_data["screenshot"] 23 | screenshot_path = screenshot_url.removeprefix(f"{settings.app_base_url}/") 24 | path = Path(screenshot_path) 25 | assert path.is_file(), f"Screenshot file does not exist: {path}" 26 | 27 | 28 | @pytest.mark.parametrize("gamemode", [g.value for g in MapGamemode]) 29 | def test_get_maps_filter_by_gamemode(client: TestClient, gamemode: MapGamemode): 30 | response = client.get("/maps", params={"gamemode": gamemode}) 31 | assert response.status_code == status.HTTP_200_OK 32 | assert all(gamemode in map_dict["gamemodes"] for map_dict in response.json()) 33 | 34 | 35 | def test_get_maps_invalid_gamemode(client: TestClient): 36 | response = client.get("/maps", params={"gamemode": "invalid"}) 37 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: "Release & Deploy" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release with Python Semantic Release 11 | runs-on: ubuntu-latest 12 | concurrency: 13 | group: ${{ github.workflow }}-release-${{ github.ref_name }} 14 | cancel-in-progress: false 15 | 16 | permissions: 17 | contents: write 18 | 19 | steps: 20 | - name: Setup | Checkout Repository 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Action | Semantic Version Release 26 | id: release 27 | # Adjust tag with desired version if applicable. 28 | uses: python-semantic-release/python-semantic-release@v10.4.1 29 | with: 30 | github_token: ${{ secrets.GITHUB_TOKEN }} 31 | git_committer_name: "github-actions" 32 | git_committer_email: "actions@users.noreply.github.com" 33 | deploy: 34 | name: Deploy on VPS 35 | needs: release 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: Deploy via SSH 40 | uses: appleboy/ssh-action@v1 41 | with: 42 | host: ${{ secrets.VPS_HOST }} 43 | port: ${{ secrets.VPS_PORT }} 44 | username: ${{ secrets.VPS_USER }} 45 | key: ${{ secrets.VPS_SSH_KEY }} 46 | script: | 47 | bash "${{ secrets.DEPLOY_SCRIPT_PATH }}" 48 | -------------------------------------------------------------------------------- /app/heroes/data/heroes.csv: -------------------------------------------------------------------------------- 1 | key,name,role,health,armor,shields 2 | ana,Ana,support,250,0,0 3 | ashe,Ashe,damage,250,0,0 4 | baptiste,Baptiste,support,250,0,0 5 | bastion,Bastion,damage,250,100,0 6 | brigitte,Brigitte,support,200,50,0 7 | cassidy,Cassidy,damage,250,0,0 8 | dva,D.Va,tank,375,325,0 9 | doomfist,Doomfist,tank,525,0,0 10 | echo,Echo,damage,150,0,75 11 | freja,Freja,damage,225,0,0 12 | genji,Genji,damage,250,0,0 13 | hazard,Hazard,tank,425,225,0 14 | hanzo,Hanzo,damage,250,0,0 15 | illari,Illari,support,250,0,0 16 | junker-queen,Junker Queen,tank,525,0,0 17 | junkrat,Junkrat,damage,250,0,0 18 | juno,Juno,support,75,0,150 19 | kiriko,Kiriko,support,225,0,0 20 | lifeweaver,Lifeweaver,support,200,0,50 21 | lucio,Lúcio,support,225,0,0 22 | mauga,Mauga,tank,575,150,0 23 | mei,Mei,damage,300,0,0 24 | mercy,Mercy,support,225,0,0 25 | moira,Moira,support,225,0,0 26 | orisa,Orisa,tank,300,300,0 27 | pharah,Pharah,damage,225,0,0 28 | ramattra,Ramattra,tank,425,100,0 29 | reaper,Reaper,damage,300,0,0 30 | reinhardt,Reinhardt,tank,400,300,0 31 | roadhog,Roadhog,tank,750,0,0 32 | sigma,Sigma,tank,350,0,275 33 | sojourn,Sojourn,damage,225,0,0 34 | soldier-76,Soldier: 76,damage,250,0,0 35 | sombra,Sombra,damage,225,0,0 36 | symmetra,Symmetra,damage,125,0,150 37 | torbjorn,Torbjörn,damage,225,75,0 38 | tracer,Tracer,damage,175,0,0 39 | vendetta,Vendetta,damage,150,125,0 40 | venture,Venture,damage,250,0,0 41 | widowmaker,Widowmaker,damage,225,0,0 42 | winston,Winston,tank,425,200,0 43 | wrecking-ball,Wrecking Ball,tank,450,125,150 44 | wuyang,Wuyang,support,225,0,0 45 | zarya,Zarya,tank,325,0,225 46 | zenyatta,Zenyatta,support,75,0,175 47 | -------------------------------------------------------------------------------- /static/gamemodes/control-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/gamemodes/data/gamemodes.csv: -------------------------------------------------------------------------------- 1 | key,name,description 2 | 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." 3 | capture-the-flag,Capture the Flag,Teams compete to capture the enemy team’s flag while defending their own. 4 | 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." 5 | control,Control,Teams fight to hold a single objective. The first team to win two rounds wins the map. 6 | deathmatch,Deathmatch,Race to reach 20 points first by racking up kills in a free-for-all format. 7 | elimination,Elimination,"Dispatch all enemies to win the round. Win three rounds to claim victory. Available with teams of one, three, or six." 8 | escort,Escort,"One team escorts a payload to its delivery point, while the other races to stop them." 9 | 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!" 10 | hybrid,Hybrid,"Attackers capture a payload, then escort it to its destination; defenders try to hold them back." 11 | payload-race,Payload Race,"Both teams get a payload to escort to the ending location while preventing the enemies from doing the same." 12 | practice-range,Practice Range,"Learn the basics, practice and test your settings." 13 | push,Push,Teams battle to take control of a robot and push it toward the enemy base. 14 | team-deathmatch,Team Deathmatch,Team up and triumph over your enemies by scoring the most kills. 15 | workshop,Workshop,"Experience custom and experimental gameplay, only in Custom Games." -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from unittest.mock import Mock, patch 3 | 4 | from app.decorators import rate_limited 5 | from app.overfast_logger import logger 6 | 7 | 8 | def test_rate_limited(): 9 | # Define the rate limited method 10 | @rate_limited(max_calls=3, interval=2) 11 | def method_to_limit(param1: int, param2: str, param3: bool): 12 | logger.info( 13 | "Here is the method to limit with %d, %s, %s", 14 | param1, 15 | param2, 16 | param3, 17 | ) 18 | 19 | # Call the method with same parameters twice 20 | method_to_limit(param1=42, param2="test", param3=True) 21 | method_to_limit(param1=42, param2="test", param3=True) 22 | 23 | logger_info_mock = Mock() 24 | with patch("app.overfast_logger.logger.info", logger_info_mock): 25 | # Call the method with same parameters a third time, 26 | # it should work fine for the next one shouldn't 27 | method_to_limit(param1=42, param2="test", param3=True) 28 | logger_info_mock.assert_called() 29 | 30 | logger_info_mock = Mock() 31 | with patch("app.overfast_logger.logger.info", logger_info_mock): 32 | # Now call the method again, it should reach the limit and not being called 33 | method_to_limit(param1=42, param2="test", param3=True) 34 | logger_info_mock.assert_not_called() 35 | 36 | # Try to call with others parameters, it should work 37 | method_to_limit(param1=3, param2="test", param3=True) 38 | logger_info_mock.assert_called() 39 | 40 | # Now sleep during interval duration and try again, it should work again 41 | sleep(2) 42 | 43 | logger_info_mock = Mock() 44 | with patch("app.overfast_logger.logger.info", logger_info_mock): 45 | method_to_limit(param1=42, param2="test", param3=True) 46 | logger_info_mock.assert_called() 47 | -------------------------------------------------------------------------------- /app/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators module""" 2 | 3 | import time 4 | from functools import wraps 5 | from typing import TYPE_CHECKING 6 | 7 | from .overfast_logger import logger 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Callable 11 | 12 | 13 | def rate_limited(max_calls: int, interval: int): 14 | """Put a rate limit on function call using specified parameters : 15 | X **max_calls** per *interval* seconds. It prevents too many calls of a 16 | given method with the exact same parameters, for example the Discord 17 | webhook if there is a critical parsing error. 18 | """ 19 | 20 | def decorator(func: Callable): 21 | call_history = {} 22 | 23 | @wraps(func) 24 | def wrapper(*args, **kwargs): 25 | # Define a unique key by using given parameters 26 | key = (args, tuple(kwargs.items())) 27 | now = time.time() 28 | 29 | # If the key is not already in history, insert it and make the call 30 | if key not in call_history: 31 | call_history[key] = [now] 32 | return func(*args, **kwargs) 33 | 34 | # Else, update the call history by removing expired limits 35 | timestamps = call_history[key] 36 | timestamps[:] = [t for t in timestamps if t >= now - interval] 37 | 38 | # If there is no limit anymore or if the max 39 | # number of calls hasn't been reached yet, continue 40 | if len(timestamps) < max_calls: 41 | timestamps.append(now) 42 | return func(*args, **kwargs) 43 | else: 44 | # Else the function is being rate limited 45 | logger.warning( 46 | "Rate limit exceeded for {} with the same " 47 | "parameters. Try again later.", 48 | func.__name__, 49 | ) 50 | return None 51 | 52 | return wrapper 53 | 54 | return decorator 55 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Build" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | name: Ruff & Pytest 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | python-version: ["3.14"] 17 | uv-version: ["0.9.2"] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up uv 23 | uses: astral-sh/setup-uv@v3 24 | with: 25 | version: "${{ matrix.uv-version }}" 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | run: uv python install ${{ matrix.python-version }} 29 | 30 | - name: Install the project 31 | run: uv sync --frozen --no-cache 32 | 33 | - name: Run Ruff code analysis 34 | run: uv run ruff check . 35 | 36 | - name: Run tests suite 37 | run: | 38 | PYTHONPATH=app/ uv run python -m pytest -v --cov-fail-under=80 --cov-report=html --cov=app/ tests/ 39 | PERCENT=$(cat htmlcov/index.html | grep "pc_cov" | awk -F '>' '{print $2}' | awk -F '%' '{print $1}') 40 | echo "COVERAGE=$PERCENT" >> $GITHUB_ENV 41 | 42 | - name: Update test coverage badge 43 | if: github.event_name == 'push' 44 | uses: schneegans/dynamic-badges-action@v1.7.0 45 | with: 46 | auth: ${{ secrets.GIST_SECRET }} 47 | gistID: 1362ebafcd51d3f65dae7935b1d322eb 48 | filename: pytest.json 49 | label: coverage 50 | message: ${{ env.COVERAGE }}% 51 | minColorRange: 50 52 | maxColorRange: 90 53 | valColorRange: ${{ env.COVERAGE }} 54 | 55 | - name: Update python version badge 56 | if: github.event_name == 'push' 57 | uses: schneegans/dynamic-badges-action@v1.7.0 58 | with: 59 | auth: ${{ secrets.GIST_SECRET }} 60 | gistID: 15a234815aa74059953a766a10e92688 61 | filename: python-version.json 62 | label: python 63 | message: v${{ matrix.python-version }} 64 | color: blue 65 | -------------------------------------------------------------------------------- /app/players/parsers/search_data_parser.py: -------------------------------------------------------------------------------- 1 | """Search Data Parser module""" 2 | 3 | from app.config import settings 4 | from app.overfast_logger import logger 5 | from app.parsers import JSONParser 6 | 7 | 8 | class SearchDataParser(JSONParser): 9 | """Static Data Parser class""" 10 | 11 | root_path = settings.search_account_path 12 | 13 | def __init__(self, **kwargs): 14 | super().__init__(**kwargs) 15 | self.player_id = kwargs.get("player_id") 16 | self.player_name = self.player_id.split("-", 1)[0] 17 | 18 | async def parse_data(self) -> dict: 19 | # As the battletag is not available on search endpoint 20 | # anymore, we'll use the name for search, by taking case into account 21 | 22 | # Find the right player 23 | matching_players = [ 24 | player 25 | for player in self.json_data 26 | if player["name"] == self.player_name and player["isPublic"] is True 27 | ] 28 | if len(matching_players) != 1: 29 | # We didn't find the player, return nothing 30 | logger.warning( 31 | "Player {} not found in search results ({} matching players)", 32 | self.player_id, 33 | len(matching_players), 34 | ) 35 | return {} 36 | 37 | player_data = matching_players[0] 38 | 39 | # Normalize optional fields that may be missing or inconsistently formatted 40 | # due to Blizzard's region-specific changes. In some regions, the "portrait" 41 | # field is still used instead of the newer "avatar", "namecard", or "title" fields. 42 | # If "portrait" is present, explicitly set "avatar", "namecard", and "title" to None 43 | # to ensure consistent data structure across all regions. 44 | if player_data.get("portrait"): 45 | player_data["avatar"] = None 46 | player_data["namecard"] = None 47 | player_data["title"] = None 48 | 49 | return player_data 50 | 51 | def get_blizzard_url(self, **kwargs) -> str: 52 | player_name = kwargs.get("player_id").split("-", 1)[0] 53 | return f"{super().get_blizzard_url(**kwargs)}/{player_name}/" 54 | -------------------------------------------------------------------------------- /tests/players/parsers/test_player_stats_summary_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.exceptions import ParserBlizzardError 8 | from tests.helpers import players_ids, unknown_player_id 9 | 10 | if TYPE_CHECKING: 11 | from app.players.parsers.player_stats_summary_parser import PlayerStatsSummaryParser 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ("player_stats_summary_parser", "player_html_data"), 16 | [(player_id, player_id) for player_id in players_ids], 17 | indirect=[ 18 | "player_stats_summary_parser", 19 | "player_html_data", 20 | ], 21 | ) 22 | @pytest.mark.asyncio 23 | async def test_player_page_parsing( 24 | player_stats_summary_parser: PlayerStatsSummaryParser, 25 | player_html_data: str, 26 | player_search_response_mock: Mock, 27 | ): 28 | with patch( 29 | "httpx.AsyncClient.get", 30 | side_effect=[ 31 | # Players search call first 32 | player_search_response_mock, 33 | # Player profile page 34 | Mock(status_code=status.HTTP_200_OK, text=player_html_data), 35 | ], 36 | ): 37 | await player_stats_summary_parser.parse() 38 | 39 | assert len(player_stats_summary_parser.data.keys()) > 0 40 | 41 | 42 | @pytest.mark.parametrize( 43 | ("player_stats_summary_parser", "player_html_data"), 44 | [(unknown_player_id, unknown_player_id)], 45 | indirect=True, 46 | ) 47 | @pytest.mark.asyncio 48 | async def test_unknown_player_parser_blizzard_error( 49 | player_stats_summary_parser: PlayerStatsSummaryParser, 50 | player_html_data: str, 51 | player_search_response_mock: Mock, 52 | ): 53 | with ( 54 | pytest.raises(ParserBlizzardError), 55 | patch( 56 | "httpx.AsyncClient.get", 57 | side_effect=[ 58 | # Players search call first 59 | player_search_response_mock, 60 | # Player profile page 61 | Mock(status_code=status.HTTP_200_OK, text=player_html_data), 62 | ], 63 | ), 64 | ): 65 | await player_stats_summary_parser.parse() 66 | -------------------------------------------------------------------------------- /tests/roles/test_roles_route.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | from fastapi import status 5 | 6 | from app.config import settings 7 | 8 | if TYPE_CHECKING: 9 | from fastapi.testclient import TestClient 10 | 11 | 12 | def test_get_roles(client: TestClient, home_html_data: str): 13 | with patch( 14 | "httpx.AsyncClient.get", 15 | return_value=Mock(status_code=status.HTTP_200_OK, text=home_html_data), 16 | ): 17 | response = client.get("/roles") 18 | assert response.status_code == status.HTTP_200_OK 19 | 20 | 21 | def test_get_roles_blizzard_error(client: TestClient): 22 | with patch( 23 | "httpx.AsyncClient.get", 24 | return_value=Mock( 25 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 26 | text="Service Unavailable", 27 | ), 28 | ): 29 | response = client.get("/roles") 30 | 31 | assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT 32 | assert response.json() == { 33 | "error": "Couldn't get Blizzard page (HTTP 503 error) : Service Unavailable", 34 | } 35 | 36 | 37 | def test_get_roles_internal_error(client: TestClient): 38 | with patch( 39 | "app.roles.controllers.list_roles_controller.ListRolesController.process_request", 40 | return_value=[{"invalid_key": "invalid_value"}], 41 | ): 42 | response = client.get("/roles") 43 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 44 | assert response.json() == {"error": settings.internal_server_error_message} 45 | 46 | 47 | def test_get_roles_blizzard_forbidden_error(client: TestClient): 48 | with patch( 49 | "httpx.AsyncClient.get", 50 | return_value=Mock( 51 | status_code=status.HTTP_403_FORBIDDEN, 52 | text="403 Forbidden", 53 | ), 54 | ): 55 | response = client.get("/roles") 56 | 57 | assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS 58 | assert response.json() == { 59 | "error": ( 60 | "API has been rate limited by Blizzard, please wait for " 61 | f"{settings.blizzard_rate_limit_retry_after} seconds before retrying" 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /tests/heroes/controllers/test_heroes_controllers.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | import pytest 4 | 5 | if TYPE_CHECKING: 6 | from app.heroes.controllers.get_hero_controller import GetHeroController 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("input_dict", "key", "new_key", "new_value"), 11 | [ 12 | # Empty dict 13 | ({}, "key", "new_key", "new_value"), 14 | # Key doesn't exist 15 | ({"key_one": 1, "key_two": 2}, "key", "new_key", "new_value"), 16 | ], 17 | ) 18 | def test_dict_insert_value_before_key_with_key_error( 19 | get_hero_controller: GetHeroController, 20 | input_dict: dict, 21 | key: str, 22 | new_key: str, 23 | new_value: Any, 24 | ): 25 | with pytest.raises(KeyError): 26 | get_hero_controller._GetHeroController__dict_insert_value_before_key( 27 | input_dict, key, new_key, new_value 28 | ) 29 | 30 | 31 | @pytest.mark.parametrize( 32 | ("input_dict", "key", "new_key", "new_value", "result_dict"), 33 | [ 34 | # Before first key 35 | ( 36 | {"key_one": 1, "key_two": 2, "key_three": 3}, 37 | "key_one", 38 | "key_four", 39 | 4, 40 | {"key_four": 4, "key_one": 1, "key_two": 2, "key_three": 3}, 41 | ), 42 | # Before middle key 43 | ( 44 | {"key_one": 1, "key_two": 2, "key_three": 3}, 45 | "key_two", 46 | "key_four", 47 | 4, 48 | {"key_one": 1, "key_four": 4, "key_two": 2, "key_three": 3}, 49 | ), 50 | # Before last key 51 | ( 52 | {"key_one": 1, "key_two": 2, "key_three": 3}, 53 | "key_three", 54 | "key_four", 55 | 4, 56 | {"key_one": 1, "key_two": 2, "key_four": 4, "key_three": 3}, 57 | ), 58 | ], 59 | ) 60 | def test_dict_insert_value_before_key_valid( 61 | get_hero_controller: GetHeroController, 62 | input_dict: dict, 63 | key: str, 64 | new_key: str, 65 | new_value: Any, 66 | result_dict: dict, 67 | ): 68 | assert ( 69 | get_hero_controller._GetHeroController__dict_insert_value_before_key( 70 | input_dict, key, new_key, new_value 71 | ) 72 | == result_dict 73 | ) 74 | -------------------------------------------------------------------------------- /tests/players/parsers/test_player_career_stats_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.exceptions import ParserBlizzardError 8 | from tests.helpers import players_ids, unknown_player_id 9 | 10 | if TYPE_CHECKING: 11 | from app.players.parsers.player_career_stats_parser import PlayerCareerStatsParser 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ("player_career_stats_parser", "player_html_data"), 16 | [(player_id, player_id) for player_id in players_ids], 17 | indirect=[ 18 | "player_career_stats_parser", 19 | "player_html_data", 20 | ], 21 | ) 22 | @pytest.mark.asyncio 23 | async def test_player_page_parsing_with_filters( 24 | player_career_stats_parser: PlayerCareerStatsParser, 25 | player_html_data: str, 26 | player_search_response_mock: Mock, 27 | ): 28 | with patch( 29 | "httpx.AsyncClient.get", 30 | side_effect=[ 31 | # Players search call first 32 | player_search_response_mock, 33 | # Player profile page 34 | Mock(status_code=status.HTTP_200_OK, text=player_html_data), 35 | ], 36 | ): 37 | await player_career_stats_parser.parse() 38 | 39 | # Just check that the parsing is working properly 40 | player_career_stats_parser.filter_request_using_query() 41 | 42 | assert len(player_career_stats_parser.data.keys()) > 0 43 | 44 | 45 | @pytest.mark.parametrize( 46 | ("player_career_stats_parser", "player_html_data"), 47 | [(unknown_player_id, unknown_player_id)], 48 | indirect=True, 49 | ) 50 | @pytest.mark.asyncio 51 | async def test_unknown_player_parser_blizzard_error( 52 | player_career_stats_parser: PlayerCareerStatsParser, 53 | player_html_data: str, 54 | player_search_response_mock: Mock, 55 | ): 56 | with ( 57 | pytest.raises(ParserBlizzardError), 58 | patch( 59 | "httpx.AsyncClient.get", 60 | side_effect=[ 61 | # Players search call first 62 | player_search_response_mock, 63 | # Player profile page 64 | Mock(status_code=status.HTTP_200_OK, text=player_html_data), 65 | ], 66 | ), 67 | ): 68 | await player_career_stats_parser.parse() 69 | -------------------------------------------------------------------------------- /app/heroes/commands/check_new_hero.py: -------------------------------------------------------------------------------- 1 | """Command used in order to check if a new hero is in the heroes list, compared 2 | to the internal heroes list. If this is a case, a Discord notification is sent to the 3 | developer. 4 | """ 5 | 6 | import asyncio 7 | 8 | from fastapi import HTTPException 9 | 10 | from app.config import settings 11 | from app.helpers import send_discord_webhook_message 12 | from app.overfast_client import OverFastClient 13 | from app.overfast_logger import logger 14 | 15 | from ..enums import HeroKey 16 | from ..parsers.heroes_parser import HeroesParser 17 | 18 | 19 | async def get_distant_hero_keys(client: OverFastClient) -> set[str]: 20 | """Get a set of Overwatch hero keys from the Blizzard heroes page""" 21 | heroes_parser = HeroesParser(client=client) 22 | 23 | try: 24 | await heroes_parser.parse() 25 | except HTTPException as error: 26 | raise SystemExit from error 27 | 28 | return {hero["key"] for hero in heroes_parser.data} 29 | 30 | 31 | def get_local_hero_keys() -> set[str]: 32 | """Get a set of Overwatch hero keys from the local HeroKey enum""" 33 | return {h.value for h in HeroKey} 34 | 35 | 36 | async def main(): 37 | """Main method of the script""" 38 | logger.info("Checking if a Discord webhook is configured...") 39 | if not settings.discord_webhook_enabled: 40 | logger.info("No Discord webhook configured ! Exiting...") 41 | raise SystemExit 42 | 43 | logger.info("OK ! Starting to check if a new hero is here...") 44 | 45 | # Instanciate one HTTPX Client to use for all the updates 46 | client = OverFastClient() 47 | 48 | distant_hero_keys = await get_distant_hero_keys(client) 49 | local_hero_keys = get_local_hero_keys() 50 | 51 | await client.aclose() 52 | 53 | # Compare both sets. If we have a difference, notify the developer 54 | new_hero_keys = distant_hero_keys - local_hero_keys 55 | if len(new_hero_keys) > 0: 56 | logger.info("New hero keys were found : {}", new_hero_keys) 57 | send_discord_webhook_message( 58 | "New Overwatch heroes detected, please add the following " 59 | f"keys into the configuration : {new_hero_keys}", 60 | ) 61 | else: 62 | logger.info("No new hero found. Exiting.") 63 | 64 | 65 | if __name__ == "__main__": # pragma: no cover 66 | logger = logger.patch(lambda record: record.update(name="check_new_hero")) 67 | asyncio.run(main()) 68 | -------------------------------------------------------------------------------- /app/players/parsers/player_career_stats_parser.py: -------------------------------------------------------------------------------- 1 | """Player stats summary Parser module""" 2 | 3 | from fastapi import status 4 | 5 | from app.exceptions import ParserBlizzardError 6 | 7 | from .player_career_parser import PlayerCareerParser 8 | 9 | 10 | class PlayerCareerStatsParser(PlayerCareerParser): 11 | """Overwatch player career Parser class""" 12 | 13 | def filter_request_using_query(self, **_) -> dict: 14 | return self._filter_stats() if self.data else {} 15 | 16 | async def parse_data(self) -> dict | None: 17 | # We must check if we have the expected section for profile. If not, 18 | # it means the player doesn't exist or hasn't been found. 19 | if not self.root_tag.css_first("blz-section.Profile-masthead"): 20 | raise ParserBlizzardError( 21 | status_code=status.HTTP_404_NOT_FOUND, 22 | message="Player not found", 23 | ) 24 | 25 | # Only return heroes stats, which will be used for calculation 26 | # depending on the parameters 27 | return self.__get_career_stats(self.get_stats()) 28 | 29 | def __get_career_stats(self, raw_stats: dict | None) -> dict | None: 30 | if not raw_stats: 31 | return None 32 | 33 | return { 34 | "stats": { 35 | platform: { 36 | gamemode: { 37 | "career_stats": { 38 | hero_key: ( 39 | { 40 | stat_group["category"]: { 41 | stat["key"]: stat["value"] 42 | for stat in stat_group["stats"] 43 | } 44 | for stat_group in statistics 45 | } 46 | if statistics 47 | else None 48 | ) 49 | for hero_key, statistics in gamemode_stats[ 50 | "career_stats" 51 | ].items() 52 | }, 53 | } 54 | for gamemode, gamemode_stats in platform_stats.items() 55 | if gamemode_stats 56 | } 57 | for platform, platform_stats in raw_stats.items() 58 | if platform_stats 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | pip-wheel-metadata/ 20 | share/python-wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .nox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | *.py,cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | db.sqlite3-journal 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # pipenv 84 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 85 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 86 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 87 | # install all needed dependencies. 88 | #Pipfile.lock 89 | 90 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 91 | __pypackages__/ 92 | 93 | # Celery stuff 94 | celerybeat-schedule 95 | celerybeat.pid 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | # Redis / Valkey dump 128 | dump.rdb 129 | 130 | # Loguru gzipped logs 131 | *.log.gz 132 | -------------------------------------------------------------------------------- /static/gamemodes/escort-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /static/gamemodes/payload-race-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | target: ${BUILD_TARGET:-main} 6 | command: /code/.venv/bin/fastapi run app/main.py --port 8080 --proxy-headers 7 | env_file: ${APP_VOLUME_PATH:-.}/.env 8 | volumes: 9 | - static_volume:/code/static 10 | - ${APP_VOLUME_PATH:-/tmp}/app-logs:/code/logs 11 | depends_on: 12 | valkey: 13 | condition: service_healthy 14 | healthcheck: 15 | test: ["CMD-SHELL", "wget --spider --quiet http://0.0.0.0:8080 || exit 1"] 16 | interval: 5s 17 | timeout: 5s 18 | 19 | worker: 20 | build: 21 | context: . 22 | target: ${BUILD_TARGET:-main} 23 | command: crond -f 24 | env_file: ${APP_VOLUME_PATH:-.}/.env 25 | volumes: 26 | - ${APP_VOLUME_PATH:-/tmp}/worker-logs:/code/logs 27 | depends_on: 28 | valkey: 29 | condition: service_healthy 30 | healthcheck: 31 | test: ["CMD-SHELL", "ps aux | grep -i crond | grep -wv grep || exit 1"] 32 | interval: 5s 33 | timeout: 2s 34 | 35 | valkey: 36 | build: 37 | context: ./build/valkey 38 | command: sh -c "./entrypoint.sh" 39 | env_file: ${APP_VOLUME_PATH:-.}/.env 40 | # Run as privileged to allow the container to change the vm.overcommit_memory setting 41 | privileged: true 42 | restart: always 43 | deploy: 44 | resources: 45 | limits: 46 | memory: ${VALKEY_MEMORY_LIMIT:-1gb} 47 | healthcheck: 48 | test: ["CMD", "valkey-cli", "ping"] 49 | interval: 3s 50 | timeout: 2s 51 | 52 | nginx: 53 | build: 54 | context: ./build/nginx 55 | ports: 56 | - "${APP_PORT}:80" 57 | entrypoint: ["/entrypoint.sh"] 58 | env_file: ${APP_VOLUME_PATH:-.}/.env 59 | volumes: 60 | - static_volume:/static 61 | depends_on: 62 | app: 63 | condition: service_healthy 64 | valkey: 65 | condition: service_healthy 66 | healthcheck: 67 | test: ["CMD-SHELL", "wget --spider --quiet http://localhost || exit 1"] 68 | interval: 5s 69 | timeout: 2s 70 | 71 | reverse-proxy: 72 | profiles: 73 | - testing 74 | image: nginx:alpine 75 | ports: 76 | - "8080:80" 77 | volumes: 78 | - ./build/reverse-proxy/default.conf:/etc/nginx/conf.d/default.conf 79 | - ./build/reverse-proxy/nginx.conf:/etc/nginx/nginx.conf 80 | depends_on: 81 | nginx: 82 | condition: service_started 83 | healthcheck: 84 | test: ["CMD-SHELL", "wget --spider --quiet http://localhost || exit 1"] 85 | interval: 5s 86 | timeout: 2s 87 | 88 | volumes: 89 | static_volume: -------------------------------------------------------------------------------- /tests/heroes/parsers/test_hero_stats_summary.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from app.exceptions import OverfastError, ParserBlizzardError 6 | from app.heroes.parsers.hero_stats_summary_parser import HeroStatsSummaryParser 7 | from app.players.enums import ( 8 | CompetitiveDivision, 9 | PlayerGamemode, 10 | PlayerPlatform, 11 | PlayerRegion, 12 | ) 13 | 14 | 15 | @pytest.mark.asyncio 16 | @pytest.mark.parametrize( 17 | ("parser_init_kwargs", "blizzard_query_params", "raises_error"), 18 | [ 19 | # Nominal case 20 | ( 21 | {}, 22 | {}, 23 | False, 24 | ), 25 | # Specific filter (tier) 26 | ( 27 | {"competitive_division": CompetitiveDivision.DIAMOND}, 28 | {"tier": "Diamond"}, 29 | False, 30 | ), 31 | # Invalid map filter (not compatible with competitive) 32 | ( 33 | {"map": "hanaoka"}, 34 | {"map": "hanaoka"}, 35 | True, 36 | ), 37 | ], 38 | ) 39 | async def test_hero_stats_summary_parser( 40 | parser_init_kwargs: dict, 41 | blizzard_query_params: dict, 42 | raises_error: bool, 43 | hero_stats_response_mock: str, 44 | ): 45 | base_kwargs = { 46 | "platform": PlayerPlatform.PC, 47 | "gamemode": PlayerGamemode.COMPETITIVE, 48 | "region": PlayerRegion.EUROPE, 49 | "order_by": "hero:asc", 50 | } 51 | init_kwargs = base_kwargs | parser_init_kwargs 52 | 53 | # Instanciate with given kwargs 54 | parser = HeroStatsSummaryParser(**init_kwargs) 55 | 56 | # Ensure running the parsing won't fail 57 | with patch("httpx.AsyncClient.get", return_value=hero_stats_response_mock): 58 | if raises_error: 59 | with pytest.raises( 60 | ParserBlizzardError, 61 | match=( 62 | f"Selected map '{init_kwargs['map']}' is not compatible " 63 | f"with '{init_kwargs['gamemode']}' gamemode" 64 | ), 65 | ): 66 | await parser.parse() 67 | else: 68 | try: 69 | await parser.parse() 70 | except OverfastError: 71 | pytest.fail("Hero stats summary parsing failed") 72 | 73 | # Ensure we're sending the right parameters to Blibli 74 | base_query_params = { 75 | "input": "PC", 76 | "rq": "1", 77 | "region": "Europe", 78 | "map": "all-maps", 79 | "tier": "All", 80 | } 81 | query_params = base_query_params | blizzard_query_params 82 | assert parser.get_blizzard_query_params(**init_kwargs) == query_params 83 | -------------------------------------------------------------------------------- /static/gamemodes/flashpoint-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Aliases 2 | docker_compose := "docker compose" 3 | docker_run := docker_compose + " run \ 4 | --volume ${PWD}/app:/code/app \ 5 | --volume ${PWD}/tests:/code/tests \ 6 | --volume ${PWD}/htmlcov:/code/htmlcov \ 7 | --volume ${PWD}/logs:/code/logs \ 8 | --volume ${PWD}/static:/code/static \ 9 | --publish 8000:8000 \ 10 | --rm \ 11 | app" 12 | 13 | # print recipe names and comments as help 14 | help: 15 | @just --list 16 | 17 | # build project images 18 | build: 19 | @echo "Building OverFastAPI (dev mode)..." 20 | BUILD_TARGET="dev" {{docker_compose}} build 21 | 22 | # run OverFastAPI application (dev mode) 23 | start: 24 | @echo "Launching OverFastAPI in dev mode with autoreload..." 25 | {{docker_run}} uv run fastapi dev app/main.py --host 0.0.0.0 26 | 27 | # run OverFastAPI application (testing mode) 28 | start_testing: 29 | @echo "Launching OverFastAPI in testing mode with reverse proxy..." 30 | {{docker_compose}} --profile testing up -d 31 | 32 | # run linter 33 | lint: 34 | @echo "Running linter..." 35 | uvx ruff check --fix --exit-non-zero-on-fix 36 | 37 | # run formatter 38 | format: 39 | @echo "Running formatter..." 40 | uvx ruff format 41 | 42 | # access an interactive shell inside the app container 43 | shell: 44 | @echo "Running shell on app container..." 45 | {{docker_run}} /bin/sh 46 | 47 | # execute a given command inside the app container 48 | exec command="": 49 | @echo "Running command on app container..." 50 | {{docker_run}} {{command}} 51 | 52 | # run tests, pytest_args can be specified 53 | test pytest_args="": 54 | @echo {{ if pytest_args != "" { "Running tests on " + pytest_args + "..." } else { "Running all tests with coverage..." } }} 55 | {{docker_run}} {{ if pytest_args != "" { "uv run python -m pytest " + pytest_args } else { "uv run python -m pytest --cov app/ --cov-report html -n auto tests/" } }} 56 | 57 | # build & run OverFastAPI application (production mode) 58 | up: 59 | @echo "Building OverFastAPI (production mode)..." 60 | {{docker_compose}} build 61 | @echo "Stopping OverFastAPI and cleaning containers..." 62 | {{docker_compose}} down -v --remove-orphans 63 | @echo "Launching OverFastAPI (production mode)..." 64 | {{docker_compose}} up -d 65 | 66 | # stop the app and remove containers 67 | down: 68 | @echo "Stopping OverFastAPI and cleaning containers..." 69 | {{docker_compose}} --profile "*" down -v --remove-orphans 70 | 71 | # clean up Docker environment 72 | clean: down 73 | @echo "Cleaning Docker environment..." 74 | docker image prune -af 75 | docker network prune -f 76 | 77 | # update lock file 78 | lock: 79 | uv lock 80 | 81 | # update test fixtures (heroes, players, etc.) 82 | update_test_fixtures params="": 83 | {{docker_run}} uv run python -m tests.update_test_fixtures {{params}} -------------------------------------------------------------------------------- /tests/test_cache_manager.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from typing import TYPE_CHECKING 3 | from unittest.mock import Mock, patch 4 | 5 | import pytest 6 | from valkey.exceptions import ValkeyError 7 | 8 | from app.cache_manager import CacheManager 9 | from app.config import settings 10 | from app.enums import Locale 11 | 12 | if TYPE_CHECKING: 13 | from fastapi import Request 14 | 15 | 16 | @pytest.fixture 17 | def cache_manager(): 18 | return CacheManager() 19 | 20 | 21 | @pytest.fixture 22 | def locale(): 23 | return Locale.ENGLISH_US 24 | 25 | 26 | @pytest.mark.parametrize( 27 | ("req", "expected"), 28 | [ 29 | (Mock(url=Mock(path="/heroes"), query_params=None), "/heroes"), 30 | ( 31 | Mock(url=Mock(path="/heroes"), query_params="role=damage"), 32 | "/heroes?role=damage", 33 | ), 34 | ( 35 | Mock(url=Mock(path="/players"), query_params="name=TeKrop"), 36 | "/players?name=TeKrop", 37 | ), 38 | ], 39 | ) 40 | def test_get_cache_key_from_request( 41 | cache_manager: CacheManager, 42 | req: Request, 43 | expected: str, 44 | ): 45 | assert cache_manager.get_cache_key_from_request(req) == expected 46 | 47 | 48 | @pytest.mark.parametrize( 49 | ("cache_key", "value", "expire", "sleep_time", "expected"), 50 | [ 51 | ("/heroes", [{"name": "Sojourn"}], 10, 0, [{"name": "Sojourn"}]), 52 | ("/heroes", [{"name": "Sojourn"}], 1, 1, None), 53 | ], 54 | ) 55 | def test_update_and_get_api_cache( 56 | cache_manager: CacheManager, 57 | cache_key: str, 58 | value: list, 59 | expire: int, 60 | sleep_time: int | None, 61 | expected: str | None, 62 | ): 63 | # Assert the value is not here before update 64 | assert cache_manager.get_api_cache(cache_key) is None 65 | 66 | # Update the API Cache and sleep if needed 67 | cache_manager.update_api_cache(cache_key, value, expire) 68 | sleep(sleep_time + 1) 69 | 70 | # Assert the value matches 71 | assert cache_manager.get_api_cache(cache_key) == expected 72 | assert cache_manager.get_api_cache("another_cache_key") is None 73 | 74 | 75 | def test_valkey_connection_error(cache_manager: CacheManager): 76 | valkey_connection_error = ValkeyError( 77 | "Error 111 connecting to 127.0.0.1:6379. Connection refused.", 78 | ) 79 | heroes_cache_key = ( 80 | f"HeroesParser-{settings.blizzard_host}/{locale}{settings.heroes_path}" 81 | ) 82 | with patch( 83 | "app.cache_manager.valkey.Valkey.get", 84 | side_effect=valkey_connection_error, 85 | ): 86 | cache_manager.update_api_cache( 87 | heroes_cache_key, 88 | [{"name": "Sojourn"}], 89 | settings.heroes_path_cache_timeout, 90 | ) 91 | assert cache_manager.get_api_cache(heroes_cache_key) is None 92 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 🤝 OverFast API Contribution Guide 2 | 3 | ## 📝 Introduction 4 | This guide aims to help you in contributing in OverFast API. The first step for you will be to understand how to technically contribute. In order to do this, you'll have to fork the repo, you can follow the [official GitHub documentation](https://docs.github.com/en/get-started/quickstart/contributing-to-projects) for more details. 5 | 6 | As of now, only some specific stuff can easily be updated by anyone, even without any knowledge in Python or FastAPI framework. If I take too much time to update them, don't hesitate to make a PR if you need up-to-date data : 7 | - The CSV file containing basic heroes data : name, role, and some statistics like health, armor and shields 8 | - The CSV file containing the list of gamemodes of the game 9 | - The CSV file containing the list of maps of the game 10 | 11 | ## 🦸 Heroes data 12 | The CSV file containing heroes statistics data is located in `app/heroes/data/heroes.csv`. Data is divided into 6 columns : 13 | - `key` : Key of the hero name, used in URLs of the API (and by Blizzard for their pages) 14 | - `name` : Display name of the hero (with the right accentuation). Used in the documentation. 15 | - `role` : Role key of the hero, which is either `damage`, `support` or `tank` 16 | - `health` : Health of the hero (in Role Queue) 17 | - `armor` : Armor of the hero, mainly possessed by tanks 18 | - `shields` : Shields of the hero 19 | 20 | ## 🎲 Gamemodes list 21 | The CSV file containing gamemodes list is located in `app/gamemodes/data/gamemodes.csv`. Data is divided into 3 columns : 22 | - `key` : Key of the gamemode, used in URLs of the API, and for the name of the corresponding screenshot and icon files 23 | - `name` : Name of the gamemode (in english) 24 | - `description` : Description of the gamemode (in english) 25 | 26 | ## 🗺️ Maps list 27 | The CSV file containing maps list is located in `app/maps/data/maps.csv`. Data is divided into 5 columns : 28 | - `key` : Key of the map, used in URLs of the API, and for the name of the corresponding screenshot file 29 | - `name` : Name of the map (in english) 30 | - `gamemodes` : List of gamemodes in which the map is playable by default 31 | - `location` : The location of the map, with the city and country (if relevant) 32 | - `country_code` : Country code of the map location if any. Don't fill this value if not relevant (ex: Horizon Lunar Colony) 33 | 34 | When adding a new map in the list, don't forget to also add its corresponding screenshot in the `static/maps` folder. File format must be `jpg`, and the name must be the value of the `key` in the CSV file. To retrieve screenshots, don't hesitate to consult the [Blizzard Press Center](https://blizzard.gamespress.com). 35 | 36 | ## 🤔 Other contributions 37 | For any other contribution you wish to make, feel free to reach me directly or check [issues page](https://github.com/TeKrop/overfast-api/issues). -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | Application code lives in `app/`, grouped by feature modules such as `app/api`, `app/common`, and parser helpers. Game data is maintained through the CSV files in `app/heroes/data`, `app/gamemodes/data`, and `app/maps/data`; keep filenames and the `key` column aligned with public URLs. Static screenshots or icons belong under `static/` (`static/maps/.jpg`). Automated and integration tests sit in `tests/` with fixtures updated via `tests/update_test_fixtures`. Root-level tooling (`justfile`, `Makefile`, `docker-compose.yml`, `.env.dist`) orchestrates local Docker workflows, while generated artifacts land in `logs/` and `htmlcov/`. 5 | 6 | ## Build, Test, and Development Commands 7 | Run `just build` once to create dev images, then `just start` for the autoreloading FastAPI app on `localhost:8000`. `just start_testing` launches the nginx + reverse-proxy profile on port `8080`, and `just down` stops all containers. Production-style smoke tests use `just up` (or `make up`). Quality automation is exposed as `just lint` (`uvx ruff check --fix`), `just format` (`uvx ruff format`), `just test` (pytest with coverage and `-n auto`), plus `just shell` or `just exec` for interactive debugging. 8 | 9 | ## Coding Style & Naming Conventions 10 | Target Python 3.12+, keep four-space indentation, and prefer explicit type hints on new public APIs. Ruff handles linting and formatting; run `just lint` and `just format` before every commit or install the bundled pre-commit hook (`pre-commit install`). Modules, variables, and functions stay `snake_case`, classes use `PascalCase`, and constants or settings are `UPPER_SNAKE_CASE`. When touching CSV datasets or static files, use lowercase, hyphenated keys that match the OverFast URL schema. Document new configuration flags inside `app/config.py` and add defaults to `.env.dist`. 11 | 12 | ## Testing Guidelines 13 | Tests run with `pytest`, driven through `just test`. Scope suites with `just test tests/api/test_players.py` or `make test PYTEST_ARGS="tests/common"`. Name files `test_.py` and preserve fixture helpers near the domain they validate. Route changes should include FastAPI client tests plus parser or CLI coverage to keep the README coverage badge meaningful. Store generated HTML coverage in `htmlcov/` and clean it only when necessary. 14 | 15 | ## Commit & Pull Request Guidelines 16 | Commits use a Conventional Commit tone (`feat:`, `fix:`, `build(deps):`, etc.) with optional scopes such as `feat(players):`. Keep diffs focused, include related CSV/static updates, and reference GitHub issues when applicable. Before submitting a PR, ensure `just lint`, `just format`, and `just test` succeed, describe the motivation, list validation steps, and attach screenshots or sample API payloads when responses change. Highlight required `.env` updates or migrations so reviewers can reproduce your environment. 17 | -------------------------------------------------------------------------------- /tests/heroes/test_heroes_route.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.config import settings 8 | from app.roles.enums import Role 9 | 10 | if TYPE_CHECKING: 11 | from fastapi.testclient import TestClient 12 | 13 | 14 | @pytest.fixture(scope="module", autouse=True) 15 | def _setup_heroes_test(heroes_html_data: str): 16 | with patch( 17 | "httpx.AsyncClient.get", 18 | return_value=Mock(status_code=status.HTTP_200_OK, text=heroes_html_data), 19 | ): 20 | yield 21 | 22 | 23 | def test_get_heroes(client: TestClient): 24 | response = client.get("/heroes") 25 | assert response.status_code == status.HTTP_200_OK 26 | assert len(response.json()) > 0 27 | 28 | 29 | @pytest.mark.parametrize("role", [r.value for r in Role]) 30 | def test_get_heroes_filter_by_role(client: TestClient, role: Role): 31 | response = client.get("/heroes", params={"role": role}) 32 | assert response.status_code == status.HTTP_200_OK 33 | assert all(hero["role"] == role for hero in response.json()) 34 | 35 | 36 | def test_get_heroes_invalid_role(client: TestClient): 37 | response = client.get("/heroes", params={"role": "invalid"}) 38 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 39 | 40 | 41 | def test_get_heroes_blizzard_error(client: TestClient): 42 | with patch( 43 | "httpx.AsyncClient.get", 44 | return_value=Mock( 45 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 46 | text="Service Unavailable", 47 | ), 48 | ): 49 | response = client.get("/heroes") 50 | 51 | assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT 52 | assert response.json() == { 53 | "error": "Couldn't get Blizzard page (HTTP 503 error) : Service Unavailable", 54 | } 55 | 56 | 57 | def test_get_heroes_internal_error(client: TestClient): 58 | with patch( 59 | "app.heroes.controllers.list_heroes_controller.ListHeroesController.process_request", 60 | return_value=[{"invalid_key": "invalid_value"}], 61 | ): 62 | response = client.get("/heroes") 63 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 64 | assert response.json() == {"error": settings.internal_server_error_message} 65 | 66 | 67 | def test_get_heroes_blizzard_forbidden_error(client: TestClient): 68 | with patch( 69 | "httpx.AsyncClient.get", 70 | return_value=Mock( 71 | status_code=status.HTTP_403_FORBIDDEN, 72 | text="403 Forbidden", 73 | ), 74 | ): 75 | response = client.get("/heroes") 76 | 77 | assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS 78 | assert response.json() == { 79 | "error": ( 80 | "API has been rate limited by Blizzard, please wait for " 81 | f"{settings.blizzard_rate_limit_retry_after} seconds before retrying" 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /static/gamemodes/push-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Default target 2 | .DEFAULT_GOAL := help 3 | 4 | # Colors 5 | YELLOW := \033[1;33m 6 | GREEN := \033[1;32m 7 | CYAN := \033[1;36m 8 | RESET := \033[0m 9 | 10 | # Aliases 11 | DOCKER_COMPOSE := docker compose 12 | 13 | DOCKER_RUN := $(DOCKER_COMPOSE) run \ 14 | --volume ${PWD}/app:/code/app \ 15 | --volume ${PWD}/tests:/code/tests \ 16 | --volume ${PWD}/htmlcov:/code/htmlcov \ 17 | --volume ${PWD}/logs:/code/logs \ 18 | --volume ${PWD}/static:/code/static \ 19 | --publish 8000:8000 \ 20 | --rm \ 21 | app 22 | 23 | help: ## Show this help message 24 | @echo "Usage: make " 25 | @echo "" 26 | @echo "${CYAN}Commands:${RESET}" 27 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?##/ {printf " ${GREEN}%-10s${RESET} : ${YELLOW}%s${RESET}\n", $$1, $$2}' $(MAKEFILE_LIST) 28 | 29 | build: ## Build project images 30 | @echo "Building OverFastAPI (dev mode)..." 31 | BUILD_TARGET="dev" $(DOCKER_COMPOSE) build 32 | 33 | start: ## Run OverFastAPI application (dev mode) 34 | @echo "Launching OverFastAPI (dev mode with autoreload)..." 35 | $(DOCKER_RUN) uv run fastapi dev app/main.py --host 0.0.0.0 36 | 37 | start_testing: ## Run OverFastAPI application (testing mode) 38 | @echo "Launching OverFastAPI (testing mode with reverse proxy)..." 39 | $(DOCKER_COMPOSE) --profile testing up -d 40 | 41 | lint: ## Run linter 42 | @echo "Running linter..." 43 | uvx ruff check --fix --exit-non-zero-on-fix 44 | 45 | format: ## Run formatter 46 | @echo "Running formatter..." 47 | uvx ruff format 48 | 49 | shell: ## Access an interactive shell inside the app container 50 | @echo "Running shell on app container..." 51 | $(DOCKER_RUN) /bin/sh 52 | 53 | exec: ## Execute a given COMMAND inside the app container 54 | @echo "Running command on app container..." 55 | $(DOCKER_RUN) $(COMMAND) 56 | 57 | test: ## Run tests, PYTEST_ARGS can be specified 58 | ifdef PYTEST_ARGS 59 | @echo "Running tests on $(PYTEST_ARGS)..." 60 | $(DOCKER_RUN) uv run python -m pytest $(PYTEST_ARGS) 61 | else 62 | @echo "Running all tests with coverage..." 63 | $(DOCKER_RUN) uv run python -m pytest --cov app/ --cov-report html -n auto tests/ 64 | endif 65 | 66 | up: ## Build & run OverFastAPI application (production mode) 67 | @echo "Building OverFastAPI (production mode)..." 68 | $(DOCKER_COMPOSE) build 69 | @echo "Stopping OverFastAPI and cleaning containers..." 70 | $(DOCKER_COMPOSE) down -v --remove-orphans 71 | @echo "Launching OverFastAPI (production mode)..." 72 | $(DOCKER_COMPOSE) up -d 73 | 74 | down: ## Stop the app and remove containers 75 | @echo "Stopping OverFastAPI and cleaning containers..." 76 | $(DOCKER_COMPOSE) --profile "*" down -v --remove-orphans 77 | 78 | clean: down ## Clean up Docker environment 79 | @echo "Cleaning Docker environment..." 80 | docker image prune -af 81 | docker network prune -f 82 | 83 | lock: ## Update lock file 84 | uv lock 85 | 86 | update_test_fixtures: ## Update test fixtures (heroes, players, etc.) 87 | $(DOCKER RUN) uv run python -m tests.update_test_fixtures $(PARAMS) 88 | 89 | .PHONY: help build start lint format shell exec test up down clean lock update_test_fixtures -------------------------------------------------------------------------------- /app/maps/data/maps.csv: -------------------------------------------------------------------------------- 1 | key,name,gamemodes,location,country_code 2 | aatlis,Aatlis,flashpoint,Morocco,MA 3 | antarctic-peninsula,Antarctic Peninsula,control,Antarctica,AQ 4 | anubis,Temple of Anubis,assault,"Giza Plateau, Egypt",EG 5 | arena-victoriae,Arena Victoriae,control,"Colosseo, Rome, Italy",IT 6 | ayutthaya,Ayutthaya,capture-the-flag,Thailand,TH 7 | black-forest,Black Forest,elimination,Germany,DE 8 | blizzard-world,Blizzard World,hybrid,"Irvine, California, United States",US 9 | busan,Busan,control,South Korea,KR 10 | castillo,Castillo,elimination,Mexico,MX 11 | chateau-guillard,Château Guillard,"deathmatch,team-deathmatch","Annecy, France",FR 12 | circuit-royal,Circuit Royal,escort,"Monte Carlo, Monaco",MC 13 | colosseo,Colosseo,push,"Rome, Italy",IT 14 | dorado,Dorado,escort,Mexico,MX 15 | ecopoint-antarctica,Ecopoint: Antarctica,elimination,Antarctica,AQ 16 | eichenwalde,Eichenwalde,hybrid,"Stuttgart, Germany",DE 17 | esperanca,Esperança,push,Portugal,PT 18 | gogadoro,Gogadoro,control,"Busan, South Korea",KR 19 | hanamura,Hanamura,assault,"Tokyo, Japan",JP 20 | hanaoka,Hanaoka,clash,"Tokyo, Japan",JP 21 | havana,Havana,escort,"Havana, Cuba",CU 22 | hollywood,Hollywood,hybrid,"Los Angeles, United States",US 23 | horizon,Horizon Lunar Colony,assault,Earth's moon, 24 | ilios,Ilios,control,Greece,GR 25 | junkertown,Junkertown,escort,Central Australia,AU 26 | lijiang-tower,Lijiang Tower,control,China,CN 27 | kanezaka,Kanezaka,"deathmatch,team-deathmatch","Tokyo, Japan",JP 28 | kings-row,King’s Row,hybrid,"London, United Kingdom",UK 29 | malevento,Malevento,"deathmatch,team-deathmatch",Italy,IT 30 | midtown,Midtown,hybrid,"New York, United States",US 31 | necropolis,Necropolis,elimination,Egypt,EG 32 | nepal,Nepal,control,Nepal,NP 33 | new-junk-city,New Junk City,flashpoint,Central Australia,AU 34 | new-queen-street,New Queen Street,push,"Toronto, Canada",CA 35 | numbani,Numbani,hybrid,Numbani (near Nigeria), 36 | oasis,Oasis,control,Iraq,IQ 37 | paraiso,Paraíso,hybrid,"Rio de Janeiro, Brazil",BR 38 | paris,Paris,assault,"Paris, France",FR 39 | petra,Petra,"deathmatch,team-deathmatch",Southern Jordan,JO 40 | place-lacroix,Place Lacroix,push,"Paris, France",FR 41 | powder-keg-mine,Powder Keg Mine,payload-race,"Deadlock Gorge, Arizona, United States",US 42 | practice-range,Practice Range,practice-range,Swiss HQ,CH 43 | redwood-dam,Redwood Dam,push,Gibraltar,GI 44 | rialto,Rialto,escort,"Venice, Italy",IT 45 | route-66,Route 66,escort,"Albuquerque, New Mexico, United States",US 46 | runasapi,Runasapi,push,Peru,PE 47 | samoa,Samoa,control,Samoa,WS 48 | shambali-monastery,Shambali Monastery,escort,Nepal,NP 49 | suravasa,Suravasa,flashpoint,India,IN 50 | thames-district,Thames District,payload-race,"London, United Kingdom",UK 51 | throne-of-anubis,Throne of Anubis,clash,"Giza Plateau, Egypt",EG 52 | volskaya,Volskaya Industries,assault,"St. Petersburg, Russia",RU 53 | watchpoint-gibraltar,Watchpoint: Gibraltar,escort,Gibraltar,GI 54 | workshop-chamber,Workshop Chamber,workshop,Earth, 55 | workshop-expanse,Workshop Expanse,workshop,Earth, 56 | workshop-green-screen,Workshop Green Screen,workshop,Earth, 57 | workshop-island,Workshop Island,workshop,Earth, -------------------------------------------------------------------------------- /tests/heroes/commands/test_check_new_hero.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.heroes.commands.check_new_hero import main as check_new_hero_main 8 | from app.heroes.enums import HeroKey 9 | 10 | 11 | @pytest.fixture(scope="module", autouse=True) 12 | def _setup_check_new_hero_test(): 13 | with patch( 14 | "app.heroes.commands.check_new_hero.settings", 15 | return_value=Mock(discord_webhook_enabled=True), 16 | ): 17 | yield 18 | 19 | 20 | def test_check_no_new_hero(heroes_html_data: str): 21 | logger_info_mock = Mock() 22 | with ( 23 | patch( 24 | "httpx.AsyncClient.get", 25 | return_value=Mock(status_code=status.HTTP_200_OK, text=heroes_html_data), 26 | ), 27 | patch("app.overfast_logger.logger.info", logger_info_mock), 28 | ): 29 | asyncio.run(check_new_hero_main()) 30 | 31 | logger_info_mock.assert_called_with("No new hero found. Exiting.") 32 | 33 | 34 | def test_check_discord_webhook_disabled(): 35 | logger_info_mock = Mock() 36 | with ( 37 | patch( 38 | "app.heroes.commands.check_new_hero.settings.discord_webhook_enabled", 39 | False, 40 | ), 41 | patch("app.overfast_logger.logger.info", logger_info_mock), 42 | pytest.raises( 43 | SystemExit, 44 | ), 45 | ): 46 | asyncio.run(check_new_hero_main()) 47 | 48 | logger_info_mock.assert_called_with("No Discord webhook configured ! Exiting...") 49 | 50 | 51 | @pytest.mark.parametrize( 52 | ("distant_heroes", "expected"), 53 | [ 54 | ({"one_new_hero"}, {"one_new_hero"}), 55 | ({"one_new_hero", "two_new_heroes"}, {"one_new_hero", "two_new_heroes"}), 56 | ({"tracer", "one_new_hero"}, {"one_new_hero"}), 57 | ], 58 | ) 59 | def test_check_new_heroes(distant_heroes: set[str], expected: set[str]): 60 | logger_info_mock = Mock() 61 | with ( 62 | patch( 63 | "app.heroes.commands.check_new_hero.get_distant_hero_keys", 64 | return_value={*HeroKey, *distant_heroes}, 65 | ), 66 | patch("app.overfast_logger.logger.info", logger_info_mock), 67 | ): 68 | asyncio.run(check_new_hero_main()) 69 | 70 | logger_info_mock.assert_called_with("New hero keys were found : {}", expected) 71 | 72 | 73 | def test_check_error_from_blizzard(): 74 | logger_error_mock = Mock() 75 | with ( 76 | patch( 77 | "httpx.AsyncClient.get", 78 | return_value=Mock( 79 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 80 | text="Internal Server Error", 81 | ), 82 | ), 83 | patch("app.overfast_logger.logger.error", logger_error_mock), 84 | pytest.raises( 85 | SystemExit, 86 | ), 87 | ): 88 | asyncio.run(check_new_hero_main()) 89 | 90 | logger_error_mock.assert_called_with( 91 | "Received an error from Blizzard. HTTP {} : {}", 92 | status.HTTP_500_INTERNAL_SERVER_ERROR, 93 | "Internal Server Error", 94 | ) 95 | -------------------------------------------------------------------------------- /app/overfast_logger.py: -------------------------------------------------------------------------------- 1 | """Custom Logger Using Loguru, inspired by Riki-1mg gist custom_logging.py""" 2 | 3 | import logging 4 | import sys 5 | from typing import TYPE_CHECKING, ClassVar 6 | 7 | from loguru import logger as loguru_logger 8 | 9 | from .config import settings 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | 15 | class InterceptHandler(logging.Handler): 16 | """InterceptionHandler class used to intercept python logs in order 17 | to transform them into loguru logs. 18 | """ 19 | 20 | loglevel_mapping: ClassVar[dict] = { 21 | 50: "CRITICAL", 22 | 40: "ERROR", 23 | 30: "WARNING", 24 | 20: "INFO", 25 | 10: "DEBUG", 26 | 0: "NOTSET", 27 | } 28 | 29 | def emit(self, record): # pragma: no cover 30 | try: 31 | level = logger.level(record.levelname).name 32 | except AttributeError: 33 | level = self.loglevel_mapping[record.levelno] 34 | 35 | frame, depth = logging.currentframe(), 2 36 | while frame.f_code.co_filename == logging.__file__: 37 | frame = frame.f_back 38 | depth += 1 39 | 40 | logger.opt(depth=depth, exception=record.exc_info).log( 41 | level, 42 | record.getMessage(), 43 | ) 44 | 45 | 46 | class OverFastLogger: 47 | @classmethod 48 | def make_logger(cls): 49 | return cls.customize_logging( 50 | f"{settings.logs_root_path}/access.log", 51 | level=settings.log_level, 52 | rotation="1 day", 53 | retention="1 year", 54 | compression="gz", 55 | log_format=( 56 | "{time:YYYY-MM-DD HH:mm:ss.SSS} | " 57 | "{level: <8} | " 58 | "{name} - {message}" 59 | ), 60 | ) 61 | 62 | @classmethod 63 | def customize_logging( 64 | cls, 65 | filepath: Path, 66 | level: str, 67 | rotation: str, 68 | retention: str, 69 | compression: str, 70 | log_format: str, 71 | ): 72 | loguru_logger.remove() 73 | loguru_logger.add( 74 | sys.stdout, 75 | enqueue=True, 76 | backtrace=True, 77 | level=level.upper(), 78 | format=log_format, 79 | ) 80 | loguru_logger.add( 81 | str(filepath), 82 | rotation=rotation, 83 | retention=retention, 84 | compression=compression, 85 | enqueue=True, 86 | backtrace=True, 87 | level=level.upper(), 88 | format=log_format, 89 | ) 90 | logging.basicConfig(handlers=[InterceptHandler()], level=0) 91 | logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] 92 | for _log in ("uvicorn", "uvicorn.error", "fastapi"): 93 | _logger = logging.getLogger(_log) 94 | _logger.handlers = [InterceptHandler()] 95 | 96 | return loguru_logger.bind(method=None) 97 | 98 | 99 | # Instanciate generic logger for all the app 100 | logger = OverFastLogger.make_logger() 101 | -------------------------------------------------------------------------------- /app/heroes/controllers/get_hero_controller.py: -------------------------------------------------------------------------------- 1 | """Hero Controller module""" 2 | 3 | from typing import Any, ClassVar 4 | 5 | from app.config import settings 6 | from app.controllers import AbstractController 7 | 8 | from ..parsers.hero_parser import HeroParser 9 | from ..parsers.heroes_parser import HeroesParser 10 | from ..parsers.heroes_stats_parser import HeroesStatsParser 11 | 12 | 13 | class GetHeroController(AbstractController): 14 | """Hero Controller used in order to retrieve data about a single 15 | Overwatch hero. The hero key given by the ListHeroesController 16 | should be used to display data about a specific hero. 17 | """ 18 | 19 | parser_classes: ClassVar[list[type]] = [HeroParser, HeroesParser, HeroesStatsParser] 20 | timeout = settings.hero_path_cache_timeout 21 | 22 | def merge_parsers_data(self, parsers_data: list[dict], **kwargs) -> dict: 23 | """Merge parsers data together : 24 | - HeroParser for detailed data 25 | - HeroesParser for portrait (not here in the specific page) 26 | - HeroesStatsParser for stats (health, armor, shields) 27 | """ 28 | hero_data, heroes_data, heroes_stats_data = parsers_data 29 | 30 | try: 31 | portrait_value = next( 32 | hero["portrait"] 33 | for hero in heroes_data 34 | if hero["key"] == kwargs.get("hero_key") 35 | ) 36 | except StopIteration: 37 | # The hero key may not be here in some specific edge cases, 38 | # for example if the hero has been released but is not in the 39 | # heroes list yet, or the list cache is outdated 40 | portrait_value = None 41 | else: 42 | # We want to insert the portrait before the "role" key 43 | hero_data = self.__dict_insert_value_before_key( 44 | hero_data, 45 | "role", 46 | "portrait", 47 | portrait_value, 48 | ) 49 | 50 | try: 51 | hitpoints = heroes_stats_data[kwargs.get("hero_key")]["hitpoints"] 52 | except KeyError: 53 | # Hero hitpoints may not be here if the CSV file 54 | # containing the data hasn't been updated 55 | hitpoints = None 56 | else: 57 | # We want to insert hitpoints before "abilities" key 58 | hero_data = self.__dict_insert_value_before_key( 59 | hero_data, 60 | "abilities", 61 | "hitpoints", 62 | hitpoints, 63 | ) 64 | 65 | return hero_data 66 | 67 | @staticmethod 68 | def __dict_insert_value_before_key( 69 | data: dict, 70 | key: str, 71 | new_key: str, 72 | new_value: Any, 73 | ) -> dict: 74 | """Insert a given key/value pair before another key in a given dict""" 75 | if key not in data: 76 | raise KeyError 77 | 78 | # Retrieve the key position 79 | key_pos = list(data.keys()).index(key) 80 | 81 | # Retrieve dict items as a list of tuples 82 | data_items = list(data.items()) 83 | 84 | # Insert the new tuple in the given position 85 | data_items.insert(key_pos, (new_key, new_value)) 86 | 87 | # Convert back the list into a dict and return it 88 | return dict(data_items) 89 | -------------------------------------------------------------------------------- /app/players/parsers/base_player_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from app.overfast_logger import logger 4 | from app.parsers import HTMLParser 5 | from app.players.parsers.search_data_parser import SearchDataParser 6 | 7 | if TYPE_CHECKING: 8 | import httpx 9 | 10 | 11 | class BasePlayerParser(HTMLParser): 12 | def __init__(self, **kwargs): 13 | super().__init__(**kwargs) 14 | self.player_id = kwargs.get("player_id") 15 | 16 | # Player Data is made of two sets of data : 17 | # - summary, retrieve from players search endpoint 18 | # - profile, gzipped HTML data from player profile page 19 | self.player_data = {"summary": None, "profile": None} 20 | 21 | def get_blizzard_url(self, **kwargs) -> str: 22 | return f"{super().get_blizzard_url(**kwargs)}/{kwargs.get('player_id')}/" 23 | 24 | def store_response_data(self, response: httpx.Response) -> None: 25 | """Store HTML data in player_data to save for Player Cache""" 26 | super().store_response_data(response) 27 | self.player_data["profile"] = response.text 28 | 29 | async def parse(self) -> None: 30 | """Main parsing method for player profile routes""" 31 | 32 | # Check if we have up-to-date data in the Player Cache 33 | logger.info("Retrieving Player Summary...") 34 | self.player_data["summary"] = await self.__retrieve_player_summary_data() 35 | 36 | # If the player wasn't found in search endpoint, we won't be able to 37 | # use internal cache using the lastUpdated value. 38 | if not self.player_data["summary"]: 39 | # Just retrieve and parse the page using the input player_id. 40 | # It could either be a Battle Tag with a name used by several players, 41 | # or the Blizzard ID retrieved from players search 42 | await super().parse() 43 | return 44 | 45 | logger.info("Checking Player Cache...") 46 | player_cache = self.cache_manager.get_player_cache(self.player_id) 47 | if ( 48 | player_cache is not None 49 | and player_cache["summary"]["lastUpdated"] 50 | == self.player_data["summary"]["lastUpdated"] 51 | ): 52 | logger.info("Player Cache found and up-to-date, using it") 53 | self.create_parser_tag(player_cache["profile"]) 54 | await self.parse_response_data() 55 | return 56 | 57 | # Data is not in Player Cache or not up-to-date, 58 | # we're retrieving data from Blizzard pages 59 | logger.info("Player Cache not found or not up-to-date, calling Blizzard") 60 | 61 | # Update URL with player summary URL 62 | self.blizzard_url = self.get_blizzard_url( 63 | player_id=self.player_data["summary"]["url"] 64 | ) 65 | await super().parse() 66 | 67 | # Update the Player Cache 68 | self.cache_manager.update_player_cache(self.player_id, self.player_data) 69 | 70 | async def __retrieve_player_summary_data(self) -> dict | None: 71 | """Call Blizzard search page with user name to 72 | check last_updated_at and retrieve summary values 73 | """ 74 | player_summary_parser = SearchDataParser(player_id=self.player_id) 75 | await player_summary_parser.parse() 76 | return player_summary_parser.data 77 | -------------------------------------------------------------------------------- /app/players/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | from app.heroes.enums import HeroKey 4 | from app.roles.enums import Role 5 | 6 | 7 | class CareerStatCategory(StrEnum): 8 | """Categories of general statistics displayed in the players API""" 9 | 10 | ASSISTS = "assists" 11 | AVERAGE = "average" 12 | BEST = "best" 13 | COMBAT = "combat" 14 | GAME = "game" 15 | HERO_SPECIFIC = "hero_specific" 16 | MATCH_AWARDS = "match_awards" 17 | MISCELLANEOUS = "miscellaneous" 18 | 19 | 20 | class CareerHeroesComparisonsCategory(StrEnum): 21 | """Categories of heroes stats in player comparisons""" 22 | 23 | TIME_PLAYED = "time_played" 24 | GAMES_WON = "games_won" 25 | WIN_PERCENTAGE = "win_percentage" 26 | WEAPON_ACCURACY_BEST_IN_GAME = "weapon_accuracy_best_in_game" 27 | ELIMINATIONS_PER_LIFE = "eliminations_per_life" 28 | KILL_STREAK_BEST = "kill_streak_best" 29 | MULTIKILL_BEST = "multikill_best" 30 | ELIMINATIONS_AVG_PER_10_MIN = "eliminations_avg_per_10_min" 31 | DEATHS_AVG_PER_10_MIN = "deaths_avg_per_10_min" 32 | FINAL_BLOWS_AVG_PER_10_MIN = "final_blows_avg_per_10_min" 33 | SOLO_KILLS_AVG_PER_10_MIN = "solo_kills_avg_per_10_min" 34 | OBJECTIVE_KILLS_AVG_PER_10_MIN = "objective_kills_avg_per_10_min" 35 | OBJECTIVE_TIME_AVG_PER_10_MIN = "objective_time_avg_per_10_min" 36 | HERO_DAMAGE_DONE_AVG_PER_10_MIN = "hero_damage_done_avg_per_10_min" 37 | HEALING_DONE_AVG_PER_10_MIN = "healing_done_avg_per_10_min" 38 | 39 | 40 | # Dynamically create the HeroKeyCareerFilter by using the existing 41 | # HeroKey enum and just adding the "all-heroes" option 42 | HeroKeyCareerFilter = StrEnum( 43 | "HeroKeyCareerFilter", 44 | { 45 | "ALL_HEROES": "all-heroes", 46 | **{hero_key.name: hero_key.value for hero_key in HeroKey}, 47 | }, 48 | ) 49 | HeroKeyCareerFilter.__doc__ = "Hero keys filter for career statistics endpoint" 50 | 51 | 52 | # Dynamically create the CompetitiveRole enum by using the existing 53 | # Role enum and just adding the "open" option for Open Queue 54 | CompetitiveRole = StrEnum( 55 | "CompetitiveRole", 56 | { 57 | **{role.name: role.value for role in Role}, 58 | "OPEN": "open", 59 | }, 60 | ) 61 | CompetitiveRole.__doc__ = "Competitive roles for ranks in stats summary" 62 | 63 | 64 | class PlayerGamemode(StrEnum): 65 | """Gamemodes associated with players statistics""" 66 | 67 | QUICKPLAY = "quickplay" 68 | COMPETITIVE = "competitive" 69 | 70 | 71 | class PlayerPlatform(StrEnum): 72 | """Players platforms""" 73 | 74 | CONSOLE = "console" 75 | PC = "pc" 76 | 77 | 78 | class CompetitiveDivision(StrEnum): 79 | """Competitive division of a rank""" 80 | 81 | BRONZE = "bronze" 82 | SILVER = "silver" 83 | GOLD = "gold" 84 | PLATINUM = "platinum" 85 | DIAMOND = "diamond" 86 | MASTER = "master" 87 | GRANDMASTER = "grandmaster" 88 | ULTIMATE = "ultimate" 89 | 90 | 91 | class PlayerRegion(StrEnum): 92 | EUROPE = "europe" 93 | AMERICAS = "americas" 94 | ASIA = "asia" 95 | 96 | 97 | # ULTIMATE competitive division doesn't exists in hero stats endpoint 98 | CompetitiveDivisionFilter = StrEnum( 99 | "CompetitiveDivisionFilter", 100 | {tier.name: tier.value for tier in CompetitiveDivision if tier.name != "ULTIMATE"}, 101 | ) 102 | CompetitiveDivisionFilter.__doc__ = ( 103 | "Competitive divisions ('grandmaster' includes 'champion')" 104 | ) 105 | -------------------------------------------------------------------------------- /app/docs.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fastapi.responses import HTMLResponse 4 | 5 | OVERFAST_REDOC_THEME = { 6 | "spacing": {"sectionVertical": 32, "sectionHorizontal": 28}, 7 | "typography": { 8 | "fontFamily": "Roboto, 'Segoe UI', Arial, sans-serif", 9 | "fontSize": "16px", 10 | "fontWeightBold": "600", 11 | "headings": {"fontWeight": "700", "lineHeight": "1.25"}, 12 | "code": {"fontFamily": "'Fira Code', Consolas, monospace"}, 13 | }, 14 | "colors": { 15 | "primary": {"main": "#ff9c00", "light": "#ffd37a"}, 16 | "success": {"main": "#1fb8ff"}, 17 | "text": { 18 | "primary": "#f4f5f7", 19 | "secondary": "#cfd3dc", 20 | "light": "#94a2c3", 21 | }, 22 | "http": { 23 | "get": "#1fb8ff", 24 | "post": "#ff9c00", 25 | "delete": "#ff5f56", 26 | "put": "#33c38c", 27 | "patch": "#b58bff", 28 | }, 29 | "responses": { 30 | "success": {"color": "#1fb8ff"}, 31 | "info": {"color": "#b58bff"}, 32 | "redirect": {"color": "#ffd37a"}, 33 | "clientError": {"color": "#ff5f56"}, 34 | "serverError": {"color": "#ff4081"}, 35 | }, 36 | "menu": {"backgroundColor": "rgba(5, 8, 15, 0.85)", "textColor": "#f4f5f7"}, 37 | "rightPanel": {"backgroundColor": "rgba(8, 11, 18, 0.9)"}, 38 | }, 39 | "menu": { 40 | "backgroundColor": "rgba(5, 8, 15, 0.6)", 41 | "textColor": "#d8deed", 42 | "groupItems": { 43 | "activeTextColor": "#ff9c00", 44 | "activeArrowColor": "#ff9c00", 45 | "textTransform": "uppercase", 46 | }, 47 | "level1Items": {"textTransform": "uppercase"}, 48 | }, 49 | "rightPanel": {"backgroundColor": "rgba(5, 8, 15, 0.65)"}, 50 | } 51 | 52 | OVERFAST_REDOC_SCRIPT = "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js" 53 | 54 | 55 | def render_documentation( 56 | *, 57 | title: str, 58 | favicon_url: str, 59 | openapi_url: str, 60 | ) -> HTMLResponse: 61 | """Render the Overwatch-themed Redoc page.""" 62 | 63 | redoc_options = {"theme": OVERFAST_REDOC_THEME, "hideLoading": True} 64 | options_json = json.dumps(redoc_options) 65 | 66 | html_content = f""" 67 | 68 | 69 | 70 | 71 | 72 | {title} 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 83 |
84 |
85 | 86 | 94 | 95 | 96 | """ 97 | 98 | return HTMLResponse(content=html_content) 99 | -------------------------------------------------------------------------------- /tests/players/test_player_summary_route.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | from httpx import TimeoutException 7 | 8 | from app.config import settings 9 | from tests.helpers import players_ids 10 | 11 | if TYPE_CHECKING: 12 | from fastapi.testclient import TestClient 13 | 14 | 15 | @pytest.mark.parametrize( 16 | ("player_id", "player_html_data"), 17 | [(player_id, player_id) for player_id in players_ids], 18 | indirect=["player_html_data"], 19 | ) 20 | def test_get_player_summary( 21 | client: TestClient, 22 | player_id: str, 23 | player_html_data: str, 24 | player_search_response_mock: Mock, 25 | ): 26 | with patch( 27 | "httpx.AsyncClient.get", 28 | side_effect=[ 29 | # Players search call first 30 | player_search_response_mock, 31 | # Player profile page 32 | Mock(status_code=status.HTTP_200_OK, text=player_html_data), 33 | ], 34 | ): 35 | response = client.get(f"/players/{player_id}/summary") 36 | assert response.status_code == status.HTTP_200_OK 37 | assert len(response.json().keys()) > 0 38 | 39 | 40 | def test_get_player_summary_blizzard_error(client: TestClient): 41 | with patch( 42 | "httpx.AsyncClient.get", 43 | return_value=Mock( 44 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 45 | text="Service Unavailable", 46 | ), 47 | ): 48 | response = client.get("/players/TeKrop-2217/summary") 49 | 50 | assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT 51 | assert response.json() == { 52 | "error": "Couldn't get Blizzard page (HTTP 503 error) : Service Unavailable", 53 | } 54 | 55 | 56 | def test_get_player_summary_blizzard_timeout(client: TestClient): 57 | with patch( 58 | "httpx.AsyncClient.get", 59 | side_effect=TimeoutException( 60 | "HTTPSConnectionPool(host='overwatch.blizzard.com', port=443): " 61 | "Read timed out. (read timeout=10)", 62 | ), 63 | ): 64 | response = client.get("/players/TeKrop-2217/summary") 65 | 66 | assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT 67 | assert response.json() == { 68 | "error": ( 69 | "Couldn't get Blizzard page (HTTP 0 error) : " 70 | "Blizzard took more than 10 seconds to respond, resulting in a timeout" 71 | ), 72 | } 73 | 74 | 75 | def test_get_player_summary_internal_error(client: TestClient): 76 | with patch( 77 | "app.players.controllers.get_player_career_controller.GetPlayerCareerController.process_request", 78 | return_value={"invalid_key": "invalid_value"}, 79 | ): 80 | response = client.get("/players/TeKrop-2217/summary") 81 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 82 | assert response.json() == {"error": settings.internal_server_error_message} 83 | 84 | 85 | def test_get_player_summary_blizzard_forbidden_error(client: TestClient): 86 | with patch( 87 | "httpx.AsyncClient.get", 88 | return_value=Mock( 89 | status_code=status.HTTP_403_FORBIDDEN, 90 | text="403 Forbidden", 91 | ), 92 | ): 93 | response = client.get("/players/TeKrop-2217/summary") 94 | 95 | assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS 96 | assert response.json() == { 97 | "error": ( 98 | "API has been rate limited by Blizzard, please wait for " 99 | f"{settings.blizzard_rate_limit_retry_after} seconds before retrying" 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /app/controllers.py: -------------------------------------------------------------------------------- 1 | """Abstract API Controller module""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | from fastapi import HTTPException, Request, Response 6 | 7 | from .cache_manager import CacheManager 8 | from .config import settings 9 | from .exceptions import ParserBlizzardError, ParserParsingError 10 | from .helpers import get_human_readable_duration, overfast_internal_error 11 | from .overfast_logger import logger 12 | 13 | 14 | class AbstractController(ABC): 15 | """Generic Abstract API Controller, containing attributes structure and methods 16 | in order to quickly be able to create concrete controllers. A controller can 17 | be associated with several parsers (one parser = one Blizzard page parsing). 18 | The API Cache system is handled here. 19 | """ 20 | 21 | # Generic cache manager class, used to manipulate Valkey cache data 22 | cache_manager = CacheManager() 23 | 24 | def __init__(self, request: Request, response: Response): 25 | self.cache_key = CacheManager.get_cache_key_from_request(request) 26 | self.response = response 27 | 28 | @property 29 | @classmethod 30 | @abstractmethod 31 | def parser_classes(cls) -> list[type]: 32 | """Parser classes used for parsing the Blizzard page retrieved with this controller""" 33 | 34 | @property 35 | @classmethod 36 | @abstractmethod 37 | def timeout(cls) -> int: 38 | """Timeout used for API Cache storage for this specific controller""" 39 | 40 | @classmethod 41 | def get_human_readable_timeout(cls) -> str: 42 | return get_human_readable_duration(cls.timeout) 43 | 44 | async def process_request(self, **kwargs) -> dict: 45 | """Main method used to process the request from user and return final data. Raises 46 | an HTTPException in case of error when retrieving or parsing data. 47 | 48 | The main steps are : 49 | - Instanciate the dedicated parser classes in order to retrieve Blizzard data 50 | - Depending on the parser, an intermediary cache can be used in the process 51 | - Filter the data using kwargs parameters, then merge the data from parsers 52 | - Update related API Cache and return the final data 53 | """ 54 | 55 | # Instance parsers and request data 56 | parsers_data = [] 57 | for parser_class in self.parser_classes: 58 | parser = parser_class(**kwargs) 59 | 60 | try: 61 | await parser.parse() 62 | except ParserBlizzardError as error: 63 | raise HTTPException( 64 | status_code=error.status_code, 65 | detail=error.message, 66 | ) from error 67 | except ParserParsingError as error: 68 | raise overfast_internal_error(parser.blizzard_url, error) from error 69 | 70 | # Filter the data to obtain final parser data 71 | logger.info("Filtering the data using query...") 72 | parsers_data.append(parser.filter_request_using_query(**kwargs)) 73 | 74 | # Merge parsers data together 75 | computed_data = self.merge_parsers_data(parsers_data, **kwargs) 76 | 77 | # Update API Cache 78 | self.cache_manager.update_api_cache(self.cache_key, computed_data, self.timeout) 79 | 80 | # Ensure response headers contains Cache TTL 81 | self.response.headers[settings.cache_ttl_header] = str(self.timeout) 82 | 83 | logger.info("Done ! Returning filtered data...") 84 | return computed_data 85 | 86 | def merge_parsers_data(self, parsers_data: list[dict | list], **_) -> dict | list: 87 | """Merge parsers data together. It depends on the given route and datas, 88 | and needs to be overriden in case a given Controller is associated 89 | with several Parsers. 90 | """ 91 | return parsers_data[0] 92 | -------------------------------------------------------------------------------- /tests/test_update_test_fixtures.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | from httpx import AsyncClient 7 | 8 | from app.config import settings 9 | from app.heroes.enums import HeroKey 10 | from tests.helpers import players_ids 11 | from tests.update_test_fixtures import ( # sourcery skip: dont-import-test-modules 12 | main as update_test_fixtures_main, 13 | ) 14 | 15 | 16 | @pytest.fixture(scope="module", autouse=True) 17 | def _setup_update_test_fixtures_test(): 18 | with ( 19 | patch( 20 | "tests.update_test_fixtures.save_fixture_file", 21 | return_value=Mock(), 22 | ), 23 | patch("app.overfast_logger.logger.debug"), 24 | ): 25 | yield 26 | 27 | 28 | test_data_path = f"{settings.test_fixtures_root_path}/html" 29 | heroes_calls = [ 30 | ("Updating {}{}...", test_data_path, "/heroes.html"), 31 | *[ 32 | ("Updating {}{}...", test_data_path, f"/heroes/{hero.value}.html") 33 | for hero in HeroKey 34 | ], 35 | ] 36 | players_calls = [ 37 | ("Updating {}{}...", test_data_path, f"/players/{player}.html") 38 | for player in players_ids 39 | ] 40 | home_calls = [("Updating {}{}...", test_data_path, "/home.html")] 41 | 42 | 43 | @pytest.mark.parametrize( 44 | ("parameters", "expected_calls"), 45 | [ 46 | (Mock(heroes=True, home=False, players=False), heroes_calls), 47 | (Mock(heroes=False, home=True, players=False), home_calls), 48 | (Mock(heroes=False, home=False, players=True), players_calls), 49 | (Mock(heroes=True, home=True, players=False), heroes_calls + home_calls), 50 | (Mock(heroes=True, home=False, players=True), heroes_calls + players_calls), 51 | (Mock(heroes=False, home=True, players=True), home_calls + players_calls), 52 | ( 53 | Mock(heroes=True, home=True, players=True), 54 | heroes_calls + home_calls + players_calls, 55 | ), 56 | ], 57 | ) 58 | def test_update_with_different_options(parameters, expected_calls: list[str]): 59 | logger_info_mock = Mock() 60 | logger_error_mock = Mock() 61 | 62 | with ( 63 | patch( 64 | "tests.update_test_fixtures.parse_parameters", 65 | return_value=parameters, 66 | ), 67 | patch.object( 68 | AsyncClient, 69 | "get", 70 | return_value=Mock(status_code=status.HTTP_200_OK, text="HTML_DATA"), 71 | ), 72 | patch( 73 | "app.overfast_logger.logger.info", 74 | logger_info_mock, 75 | ), 76 | patch( 77 | "app.overfast_logger.logger.error", 78 | logger_error_mock, 79 | ), 80 | ): 81 | asyncio.run(update_test_fixtures_main()) 82 | 83 | for expected in expected_calls: 84 | logger_info_mock.assert_any_call(*expected) 85 | logger_error_mock.assert_not_called() 86 | 87 | 88 | def test_update_with_blizzard_error(): 89 | logger_error_mock = Mock() 90 | 91 | with ( 92 | patch( 93 | "tests.update_test_fixtures.parse_parameters", 94 | return_value=Mock(heroes=False, players=False, maps=True), 95 | ), 96 | patch.object( 97 | AsyncClient, 98 | "get", 99 | return_value=Mock( 100 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 101 | text="BLIZZARD_ERROR", 102 | ), 103 | ), 104 | patch( 105 | "app.overfast_logger.logger.info", 106 | Mock(), 107 | ), 108 | patch( 109 | "app.overfast_logger.logger.error", 110 | logger_error_mock, 111 | ), 112 | ): 113 | asyncio.run(update_test_fixtures_main()) 114 | 115 | logger_error_mock.assert_called_with( 116 | "Error while getting the page : {}", 117 | "BLIZZARD_ERROR", 118 | ) 119 | -------------------------------------------------------------------------------- /tests/heroes/parsers/test_hero_parser.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.config import settings 8 | from app.enums import Locale 9 | from app.exceptions import OverfastError, ParserBlizzardError 10 | from app.heroes.enums import HeroKey 11 | 12 | if TYPE_CHECKING: 13 | from app.heroes.parsers.hero_parser import HeroParser 14 | 15 | 16 | @pytest.mark.parametrize( 17 | ("hero_key", "hero_html_data"), 18 | [(h.value, h.value) for h in HeroKey], 19 | indirect=["hero_html_data"], 20 | ) 21 | @pytest.mark.asyncio 22 | async def test_hero_page_parsing( 23 | hero_parser: HeroParser, hero_key: str, hero_html_data: str 24 | ): 25 | if not hero_html_data: 26 | pytest.skip("Hero HTML file not saved yet, skipping") 27 | 28 | with patch( 29 | "httpx.AsyncClient.get", 30 | return_value=Mock(status_code=status.HTTP_200_OK, text=hero_html_data), 31 | ): 32 | try: 33 | await hero_parser.parse() 34 | except OverfastError: 35 | pytest.fail(f"Hero page parsing failed for '{hero_key}' hero") 36 | 37 | 38 | @pytest.mark.parametrize("hero_html_data", ["unknown-hero"], indirect=True) 39 | @pytest.mark.asyncio 40 | async def test_not_released_hero_parser_blizzard_error( 41 | hero_parser: HeroParser, hero_html_data: str 42 | ): 43 | with ( 44 | pytest.raises(ParserBlizzardError), 45 | patch( 46 | "httpx.AsyncClient.get", 47 | return_value=Mock( 48 | status_code=status.HTTP_404_NOT_FOUND, text=hero_html_data 49 | ), 50 | ), 51 | ): 52 | await hero_parser.parse() 53 | 54 | 55 | @pytest.mark.parametrize( 56 | ("url", "full_url"), 57 | [ 58 | ( 59 | "https://www.youtube.com/watch?v=yzFWIw7wV8Q", 60 | "https://www.youtube.com/watch?v=yzFWIw7wV8Q", 61 | ), 62 | ("/media/stories/bastet", f"{settings.blizzard_host}/media/stories/bastet"), 63 | ], 64 | ) 65 | def test_get_full_url(hero_parser: HeroParser, url: str, full_url: str): 66 | assert hero_parser._HeroParser__get_full_url(url) == full_url 67 | 68 | 69 | @pytest.mark.parametrize( 70 | ("input_str", "locale", "result"), 71 | [ 72 | # Classic cases 73 | ("Aug 19 (Age: 37)", Locale.ENGLISH_US, ("Aug 19", 37)), 74 | ("May 9 (Age: 1)", Locale.ENGLISH_US, ("May 9", 1)), 75 | # Specific unknown case (bastion) 76 | ("Unknown (Age: 32)", Locale.ENGLISH_US, (None, 32)), 77 | # Specific venture case (not the same spacing) 78 | ("Aug 6 (Age : 26)", Locale.ENGLISH_US, ("Aug 6", 26)), 79 | ("Aug 6 (Age : 26)", Locale.ENGLISH_EU, ("Aug 6", 26)), 80 | # Other languages than english 81 | ("6. Aug. (Alter: 26)", Locale.GERMAN, ("6. Aug.", 26)), 82 | ("6 ago (Edad: 26)", Locale.SPANISH_EU, ("6 ago", 26)), 83 | ("6 ago (Edad: 26)", Locale.SPANISH_LATIN, ("6 ago", 26)), 84 | ("6 août (Âge : 26 ans)", Locale.FRENCH, ("6 août", 26)), 85 | ("6 ago (Età: 26)", Locale.ITALIANO, ("6 ago", 26)), 86 | ("8月6日 (年齢: 26)", Locale.JAPANESE, ("8月6日", 26)), 87 | ("8월 6일 (나이: 26세)", Locale.KOREAN, ("8월 6일", 26)), 88 | ("6 sie (Wiek: 26 lat)", Locale.POLISH, ("6 sie", 26)), 89 | ("6 de ago. (Idade: 26)", Locale.PORTUGUESE_BRAZIL, ("6 de ago.", 26)), 90 | ("6 авг. (Возраст: 26)", Locale.RUSSIAN, ("6 авг.", 26)), 91 | ("8月6日 (年齡:26)", Locale.CHINESE_TAIWAN, ("8月6日", 26)), 92 | # Invalid case 93 | ("Unknown", Locale.ENGLISH_US, (None, None)), 94 | ], 95 | ) 96 | def test_get_birthday_and_age( 97 | hero_parser: HeroParser, 98 | input_str: str, 99 | locale: Locale, 100 | result: tuple[str | None, int | None], 101 | ): 102 | """Get birthday and age from text for a given hero""" 103 | assert hero_parser._HeroParser__get_birthday_and_age(input_str, locale) == result 104 | -------------------------------------------------------------------------------- /static/gamemodes/hybrid-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/players/parsers/player_search_parser.py: -------------------------------------------------------------------------------- 1 | """Player stats summary Parser module""" 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from app.config import settings 6 | from app.overfast_logger import logger 7 | from app.parsers import JSONParser 8 | 9 | from ..helpers import get_player_title 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Iterable 13 | 14 | 15 | class PlayerSearchParser(JSONParser): 16 | """Overwatch player search Parser class""" 17 | 18 | root_path = settings.search_account_path 19 | 20 | def __init__(self, **kwargs): 21 | super().__init__(**kwargs) 22 | self.search_nickname = kwargs["name"] 23 | self.search_name = kwargs["name"].split("-", 1)[0] 24 | self.order_by = kwargs.get("order_by") 25 | self.offset = kwargs.get("offset") 26 | self.limit = kwargs.get("limit") 27 | 28 | def get_blizzard_url(self, **kwargs) -> str: 29 | """URL used when requesting data to Blizzard.""" 30 | search_name = kwargs["name"].split("-", 1)[0] 31 | return f"{super().get_blizzard_url(**kwargs)}/{search_name}/" 32 | 33 | async def parse_data(self) -> dict: 34 | # First filter players given their name 35 | players = self.filter_players() 36 | 37 | # Transform into PlayerSearchResult format 38 | logger.info("Applying transformation..") 39 | players = self.apply_transformations(players) 40 | 41 | # Apply ordering 42 | logger.info("Applying ordering..") 43 | players = self.apply_ordering(players) 44 | 45 | players_list = { 46 | "total": len(players), 47 | "results": players[self.offset : self.offset + self.limit], 48 | } 49 | 50 | logger.info("Done ! Returning players list...") 51 | return players_list 52 | 53 | def filter_players(self) -> list[dict]: 54 | """Filter players before transforming. Ensure resulting players 55 | have the exact same name, with same letter case. 56 | """ 57 | return [ 58 | player 59 | for player in self.json_data 60 | if player["name"] == self.search_name and player["isPublic"] is True 61 | ] 62 | 63 | def apply_transformations(self, players: Iterable[dict]) -> list[dict]: 64 | """Apply transformations to found players in order to return the data 65 | in the OverFast API format. We'll also retrieve some data from parsers. 66 | """ 67 | transformed_players = [] 68 | 69 | for player in players: 70 | player_id = ( 71 | self.search_nickname 72 | if len(players) == 1 and "-" in self.search_nickname 73 | else player["url"] 74 | ) 75 | 76 | # Normalize optional fields that may be missing or inconsistently formatted 77 | # due to Blizzard's region-specific changes. In some regions, the "portrait" 78 | # field is still used instead of the newer "avatar", "namecard", or "title" fields. 79 | # If "portrait" is present, explicitly set "avatar", "namecard", and "title" to None 80 | # to ensure consistent data structure across all regions. 81 | if player.get("portrait"): 82 | player["avatar"] = None 83 | player["namecard"] = None 84 | player["title"] = None 85 | 86 | transformed_players.append( 87 | { 88 | "player_id": player_id, 89 | "name": player["name"], 90 | "avatar": player["avatar"], 91 | "namecard": player.get("namecard"), 92 | "title": get_player_title(player.get("title")), 93 | "career_url": f"{settings.app_base_url}/players/{player_id}", 94 | "blizzard_id": player["url"], 95 | "last_updated_at": player["lastUpdated"], 96 | "is_public": player["isPublic"], 97 | }, 98 | ) 99 | return transformed_players 100 | 101 | def apply_ordering(self, players: list[dict]) -> list[dict]: 102 | """Apply the given ordering to the list of found players.""" 103 | order_field, order_arrangement = self.order_by.split(":") 104 | players.sort( 105 | key=lambda player: player[order_field], 106 | reverse=order_arrangement == "desc", 107 | ) 108 | return players 109 | -------------------------------------------------------------------------------- /app/helpers.py: -------------------------------------------------------------------------------- 1 | """Parser Helpers module""" 2 | 3 | import csv 4 | import traceback 5 | from functools import cache 6 | from pathlib import Path 7 | 8 | import httpx 9 | from fastapi import HTTPException, status 10 | 11 | from .config import settings 12 | from .decorators import rate_limited 13 | from .models import ( 14 | BlizzardErrorMessage, 15 | InternalServerErrorMessage, 16 | RateLimitErrorMessage, 17 | ) 18 | from .overfast_logger import logger 19 | 20 | # Typical routes responses to return 21 | success_responses = { 22 | status.HTTP_200_OK: { 23 | "description": "Successful Response", 24 | "headers": { 25 | settings.cache_ttl_header: { 26 | "description": "The TTL value for the cached response, in seconds", 27 | "schema": { 28 | "type": "string", 29 | "example": "600", 30 | }, 31 | }, 32 | }, 33 | }, 34 | } 35 | 36 | routes_responses = { 37 | **success_responses, 38 | status.HTTP_429_TOO_MANY_REQUESTS: { 39 | "model": RateLimitErrorMessage, 40 | "description": "Rate Limit Error", 41 | "headers": { 42 | settings.retry_after_header: { 43 | "description": "Indicates how long to wait before making a new request", 44 | "schema": { 45 | "type": "string", 46 | "example": "5", 47 | }, 48 | } 49 | }, 50 | }, 51 | status.HTTP_500_INTERNAL_SERVER_ERROR: { 52 | "model": InternalServerErrorMessage, 53 | "description": "Internal Server Error", 54 | }, 55 | status.HTTP_504_GATEWAY_TIMEOUT: { 56 | "model": BlizzardErrorMessage, 57 | "description": "Blizzard Server Error", 58 | }, 59 | } 60 | 61 | 62 | def overfast_internal_error(url: str, error: Exception) -> HTTPException: 63 | """Returns an Internal Server Error. Also log it and eventually send 64 | a Discord notification via a webhook if configured. 65 | """ 66 | 67 | # Log the critical error 68 | logger.critical( 69 | "Internal server error for URL {} : {}\n{}", 70 | url, 71 | str(error), 72 | traceback.format_stack(), 73 | ) 74 | 75 | # If we're using a profiler, it means we're debugging, raise the error 76 | # directly in order to have proper backtrace in logs 77 | if settings.profiler: 78 | raise error # pragma: no cover 79 | 80 | # Else, send a message to the given channel using Discord Webhook URL 81 | send_discord_webhook_message( 82 | f"* **URL** : {url}\n" 83 | f"* **Error type** : {type(error).__name__}\n" 84 | f"* **Message** : {error}", 85 | ) 86 | 87 | return HTTPException( 88 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 89 | detail=settings.internal_server_error_message, 90 | ) 91 | 92 | 93 | @rate_limited(max_calls=1, interval=1800) 94 | def send_discord_webhook_message(message: str) -> httpx.Response | None: 95 | """Helper method for sending a Discord webhook message. It's limited to 96 | one call per 30 minutes with the same parameters.""" 97 | if not settings.discord_webhook_enabled: 98 | logger.error(message) 99 | return None 100 | 101 | return httpx.post( # pragma: no cover 102 | settings.discord_webhook_url, data={"content": message}, timeout=10 103 | ) 104 | 105 | 106 | @cache 107 | def read_csv_data_file(filename: str) -> list[dict[str, str]]: 108 | """Helper method for obtaining CSV DictReader from a path""" 109 | with Path(f"{Path.cwd()}/app/{filename}/data/{filename}.csv").open( 110 | encoding="utf-8" 111 | ) as csv_file: 112 | return list(csv.DictReader(csv_file, delimiter=",")) 113 | 114 | 115 | @cache 116 | def get_human_readable_duration(duration: int) -> str: 117 | # Define the time units 118 | days, remainder = divmod(duration, 86400) 119 | hours, remainder = divmod(remainder, 3600) 120 | minutes, _ = divmod(remainder, 60) 121 | 122 | # Build the human-readable string 123 | duration_parts = [] 124 | if days > 0: 125 | duration_parts.append(f"{days} day{'s' if days > 1 else ''}") 126 | if hours > 0: 127 | duration_parts.append(f"{hours} hour{'s' if hours > 1 else ''}") 128 | if minutes > 0: 129 | duration_parts.append(f"{minutes} minute{'s' if minutes > 1 else ''}") 130 | 131 | return ", ".join(duration_parts) 132 | -------------------------------------------------------------------------------- /static/gamemodes/elimination-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/heroes/test_hero_stats_route.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import status 6 | 7 | from app.config import settings 8 | from app.players.enums import PlayerGamemode, PlayerPlatform, PlayerRegion 9 | 10 | if TYPE_CHECKING: 11 | from fastapi.testclient import TestClient 12 | 13 | 14 | @pytest.fixture(scope="module", autouse=True) 15 | def _setup_hero_stats_test(hero_stats_response_mock: Mock): 16 | with patch("httpx.AsyncClient.get", return_value=hero_stats_response_mock): 17 | yield 18 | 19 | 20 | def test_get_hero_stats_missing_parameters(client: TestClient): 21 | response = client.get("/heroes/stats") 22 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 23 | 24 | 25 | def test_get_hero_stats_success(client: TestClient): 26 | response = client.get( 27 | "/heroes/stats", 28 | params={ 29 | "platform": PlayerPlatform.PC, 30 | "gamemode": PlayerGamemode.QUICKPLAY, 31 | "region": PlayerRegion.EUROPE, 32 | }, 33 | ) 34 | assert response.status_code == status.HTTP_200_OK 35 | assert len(response.json()) > 0 36 | 37 | 38 | def test_get_hero_stats_invalid_platform(client: TestClient): 39 | response = client.get( 40 | "/heroes/stats", 41 | params={ 42 | "platform": "invalid_platform", 43 | "gamemode": PlayerGamemode.QUICKPLAY, 44 | "region": PlayerRegion.EUROPE, 45 | }, 46 | ) 47 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 48 | 49 | 50 | def test_get_hero_stats_invalid_gamemode(client: TestClient): 51 | response = client.get( 52 | "/heroes/stats", 53 | params={ 54 | "platform": PlayerPlatform.PC, 55 | "gamemode": "invalid_gamemode", 56 | "region": PlayerRegion.EUROPE, 57 | }, 58 | ) 59 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 60 | 61 | 62 | def test_get_hero_stats_invalid_region(client: TestClient): 63 | response = client.get( 64 | "/heroes/stats", 65 | params={ 66 | "platform": PlayerPlatform.PC, 67 | "gamemode": PlayerGamemode.QUICKPLAY, 68 | "region": "invalid_region", 69 | }, 70 | ) 71 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 72 | 73 | 74 | def test_get_hero_stats_blizzard_error(client: TestClient): 75 | with patch( 76 | "httpx.AsyncClient.get", 77 | return_value=Mock( 78 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 79 | text="Service Unavailable", 80 | ), 81 | ): 82 | response = client.get( 83 | "/heroes/stats", 84 | params={ 85 | "platform": PlayerPlatform.PC, 86 | "gamemode": PlayerGamemode.QUICKPLAY, 87 | "region": PlayerRegion.EUROPE, 88 | }, 89 | ) 90 | 91 | assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT 92 | assert response.json() == { 93 | "error": "Couldn't get Blizzard page (HTTP 503 error) : Service Unavailable", 94 | } 95 | 96 | 97 | def test_get_heroes_internal_error(client: TestClient): 98 | with patch( 99 | "app.heroes.controllers.get_hero_stats_summary_controller.GetHeroStatsSummaryController.process_request", 100 | return_value=[{"invalid_key": "invalid_value"}], 101 | ): 102 | response = client.get( 103 | "/heroes/stats", 104 | params={ 105 | "platform": PlayerPlatform.PC, 106 | "gamemode": PlayerGamemode.QUICKPLAY, 107 | "region": PlayerRegion.EUROPE, 108 | }, 109 | ) 110 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 111 | assert response.json() == {"error": settings.internal_server_error_message} 112 | 113 | 114 | def test_get_heroes_blizzard_forbidden_error(client: TestClient): 115 | with patch( 116 | "httpx.AsyncClient.get", 117 | return_value=Mock( 118 | status_code=status.HTTP_403_FORBIDDEN, 119 | text="403 Forbidden", 120 | ), 121 | ): 122 | response = client.get( 123 | "/heroes/stats", 124 | params={ 125 | "platform": PlayerPlatform.PC, 126 | "gamemode": PlayerGamemode.QUICKPLAY, 127 | "region": PlayerRegion.EUROPE, 128 | }, 129 | ) 130 | 131 | assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS 132 | assert response.json() == { 133 | "error": ( 134 | "API has been rate limited by Blizzard, please wait for " 135 | f"{settings.blizzard_rate_limit_retry_after} seconds before retrying" 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /tests/update_test_fixtures.py: -------------------------------------------------------------------------------- 1 | """Update Parsers Test Fixtures module 2 | Using Blizzard real data about heroes, some players and maps, 3 | download and update parsers test HTML fixtures 4 | """ 5 | 6 | import argparse 7 | import asyncio 8 | from pathlib import Path 9 | 10 | import httpx 11 | from fastapi import status 12 | 13 | from app.config import settings 14 | from app.enums import Locale 15 | from app.overfast_logger import logger 16 | from app.players.enums import HeroKey 17 | from tests.helpers import players_ids, unknown_player_id 18 | 19 | 20 | def parse_parameters() -> argparse.Namespace: # pragma: no cover 21 | """Parse command line arguments and returns the corresponding Namespace object""" 22 | parser = argparse.ArgumentParser( 23 | description=( 24 | "Update test data fixtures by retrieving Blizzard pages directly. " 25 | "By default, all the tests data will be updated." 26 | ), 27 | ) 28 | parser.add_argument( 29 | "-He", 30 | "--heroes", 31 | action="store_true", 32 | default=False, 33 | help="update heroes test data", 34 | ) 35 | parser.add_argument( 36 | "-Ho", 37 | "--home", 38 | action="store_true", 39 | default=False, 40 | help="update home test data (roles, gamemodes)", 41 | ) 42 | parser.add_argument( 43 | "-P", 44 | "--players", 45 | action="store_true", 46 | default=False, 47 | help="update players test data", 48 | ) 49 | 50 | args = parser.parse_args() 51 | 52 | # If no value was given by the user, all is true 53 | if not any(vars(args).values()): 54 | args.heroes = True 55 | args.home = True 56 | args.players = True 57 | 58 | return args 59 | 60 | 61 | def list_routes_to_update(args: argparse.Namespace) -> dict[str, str]: 62 | """Method used to construct the dict of routes to update. The result 63 | is dictionnary, mapping the blizzard route path to the local filepath.""" 64 | route_file_mapping = {} 65 | 66 | if args.heroes: 67 | logger.info("Adding heroes routes...") 68 | 69 | route_file_mapping |= { 70 | f"{settings.heroes_path}/": "/heroes.html", 71 | **{ 72 | f"{settings.heroes_path}/{hero.value}/": f"/heroes/{hero.value}.html" 73 | for hero in HeroKey 74 | }, 75 | } 76 | 77 | if args.players: 78 | logger.info("Adding player careers routes...") 79 | route_file_mapping.update( 80 | **{ 81 | f"{settings.career_path}/{player_id}/": f"/players/{player_id}.html" 82 | for player_id in [*players_ids, unknown_player_id] 83 | }, 84 | ) 85 | 86 | if args.home: 87 | logger.info("Adding home routes...") 88 | route_file_mapping[settings.home_path] = "/home.html" 89 | 90 | return route_file_mapping 91 | 92 | 93 | def save_fixture_file(filepath: str, content: str): # pragma: no cover 94 | """Method used to save the fixture file on the disk""" 95 | with Path(filepath).open(mode="w", encoding="utf-8") as html_file: 96 | html_file.write(content) 97 | html_file.close() 98 | logger.info("File saved !") 99 | 100 | 101 | async def main(): 102 | """Main method of the script""" 103 | logger.info("Updating test fixtures...") 104 | 105 | args = parse_parameters() 106 | logger.debug("args : {}", args) 107 | 108 | # Initialize data 109 | route_file_mapping = list_routes_to_update(args) 110 | locale = Locale.ENGLISH_US 111 | 112 | # Do the job 113 | test_data_path = f"{settings.test_fixtures_root_path}/html" 114 | async with httpx.AsyncClient() as client: 115 | for route, filepath in route_file_mapping.items(): 116 | logger.info("Updating {}{}...", test_data_path, filepath) 117 | logger.info("GET {}/{}{}...", settings.blizzard_host, locale, route) 118 | response = await client.get( 119 | f"{settings.blizzard_host}/{locale}{route}", 120 | headers={"Accept": "text/html"}, 121 | follow_redirects=True, 122 | ) 123 | logger.debug( 124 | "HTTP {} / Time : {}", 125 | response.status_code, 126 | response.elapsed.total_seconds(), 127 | ) 128 | if response.status_code in {status.HTTP_200_OK, status.HTTP_404_NOT_FOUND}: 129 | save_fixture_file(f"{test_data_path}{filepath}", response.text) 130 | else: 131 | logger.error("Error while getting the page : {}", response.text) 132 | 133 | logger.info("Fixtures update finished !") 134 | 135 | 136 | if __name__ == "__main__": # pragma: no cover 137 | logger = logger.patch(lambda record: record.update(name="update_test_fixtures")) 138 | asyncio.run(main()) 139 | -------------------------------------------------------------------------------- /static/overwatch-redoc.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --of-bg: #04070f; 3 | --of-bg-alt: #0e1321; 4 | --of-orange: #ff9c00; 5 | --of-blue: #1fb8ff; 6 | --of-text: #f4f5f7; 7 | --of-muted: #94a2c3; 8 | --of-border: rgba(255, 255, 255, 0.08); 9 | --of-code-bg: rgba(3, 6, 12, 0.85); 10 | } 11 | 12 | *, 13 | *::before, 14 | *::after { 15 | box-sizing: border-box; 16 | } 17 | 18 | body { 19 | margin: 0; 20 | min-height: 100vh; 21 | font-family: "Roboto", "Segoe UI", Arial, sans-serif; 22 | color: var(--of-text); 23 | background: #121a31; 24 | background-attachment: fixed; 25 | } 26 | 27 | a { 28 | color: inherit; 29 | } 30 | 31 | .redoc-wrap { 32 | background: transparent !important; 33 | } 34 | 35 | .redoc-wrap .menu-content { 36 | background: rgba(4, 7, 15, 0.85) !important; 37 | border-right: 1px solid var(--of-border); 38 | } 39 | 40 | .redoc-wrap .menu-content label { 41 | color: var(--of-muted) !important; 42 | font-weight: 600; 43 | letter-spacing: 0.08em; 44 | text-transform: uppercase; 45 | } 46 | 47 | .redoc-wrap .menu-content .menu-item, 48 | .redoc-wrap .menu-content .menu-item span { 49 | color: var(--of-text) !important; 50 | } 51 | 52 | .redoc-wrap .menu-content .menu-item.active { 53 | color: var(--of-orange) !important; 54 | } 55 | 56 | .redoc-wrap .api-content { 57 | background: transparent !important; 58 | } 59 | 60 | .redoc-wrap .api-content > div > div > div:last-child { 61 | background: none; 62 | } 63 | 64 | .redoc-wrap .api-content > div > div > div:last-child > div:first-child > div:last-child { 65 | background: #090e1b; 66 | color: var(--of-text); 67 | } 68 | 69 | .redoc-wrap .api-content > div > div > div:last-child > div:first-child > div:last-child div[role=button] > div { 70 | background: var(--of-bg); 71 | } 72 | 73 | .redoc-wrap > div:last-child { 74 | background: #090e1b; 75 | } 76 | 77 | .redoc-wrap .api-content span { 78 | color: var(--of-text); 79 | } 80 | 81 | .redoc-wrap h1, 82 | .redoc-wrap h2, 83 | .redoc-wrap h3, 84 | .redoc-wrap h4, 85 | .redoc-wrap h5, 86 | .redoc-wrap h5 span { 87 | color: var(--of-text) !important; 88 | font-family: Roboto; 89 | } 90 | 91 | .redoc-wrap .section-title, 92 | .redoc-wrap .section-title span, 93 | .redoc-wrap .section-title small, 94 | .redoc-wrap .response-title, 95 | .redoc-wrap .response-section__title, 96 | .redoc-wrap .responses-title, 97 | .redoc-wrap .tab-item, 98 | .redoc-wrap .tab-item span { 99 | color: var(--of-text) !important; 100 | } 101 | 102 | .redoc-wrap .label, 103 | .redoc-wrap .response__status, 104 | .redoc-wrap .body-title, 105 | .redoc-wrap .param-name { 106 | color: var(--of-muted) !important; 107 | } 108 | 109 | .redoc-wrap code, 110 | .redoc-wrap .code, 111 | .redoc-wrap pre code { 112 | font-family: "Fira Code", Consolas, monospace !important; 113 | font-weight: 500; 114 | background: var(--of-code-bg) !important; 115 | color: #ffd37a !important; 116 | } 117 | 118 | .redoc-wrap pre { 119 | border-radius: 1rem !important; 120 | border: 1px solid var(--of-border) !important; 121 | background: var(--of-code-bg) !important; 122 | box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.35); 123 | } 124 | 125 | .redoc-wrap table { 126 | border-color: var(--of-border) !important; 127 | } 128 | 129 | .redoc-wrap table caption { 130 | color: var(--of-text); 131 | border-color: var(--of-border); 132 | } 133 | 134 | .redoc-wrap table thead th, 135 | .redoc-wrap table tbody td { 136 | color: var(--of-text) !important; 137 | } 138 | 139 | .redoc-wrap table tbody tr:nth-child(odd) { 140 | background: rgba(255, 255, 255, 0.02) !important; 141 | } 142 | 143 | .redoc-wrap table tbody tr td div { 144 | background: none !important; 145 | } 146 | 147 | .redoc-wrap [class*="json-schema"], 148 | .redoc-wrap [class*="json-tree"] { 149 | color: var(--of-text) !important; 150 | } 151 | 152 | .redoc-wrap [class*="json-schema"] .property-name, 153 | .redoc-wrap [class*="json-schema"] .json-property, 154 | .redoc-wrap [class*="json-tree"] .token.property { 155 | color: #ffd37a !important; 156 | } 157 | 158 | .redoc-wrap [class*="json-schema"] .json-value, 159 | .redoc-wrap [class*="json-tree"] .token.string { 160 | color: #ffe7a3 !important; 161 | } 162 | 163 | .redoc-wrap [class*="json-tree"] .token.number { 164 | color: var(--of-blue) !important; 165 | } 166 | 167 | .redoc-wrap .hljs-string { 168 | color: #ffe7a3 !important; 169 | } 170 | 171 | .redoc-wrap .hljs-number { 172 | color: var(--of-blue) !important; 173 | } 174 | 175 | .redoc-wrap .hljs-attr, 176 | .redoc-wrap .hljs-attribute { 177 | color: #ffd37a !important; 178 | } 179 | 180 | ::-webkit-scrollbar { 181 | width: 10px; 182 | } 183 | 184 | ::-webkit-scrollbar-track { 185 | background: rgba(255, 255, 255, 0.05); 186 | } 187 | 188 | ::-webkit-scrollbar-thumb { 189 | background: rgba(255, 156, 0, 0.4); 190 | border-radius: 999px; 191 | } 192 | 193 | #overfast-loader { 194 | width: 50px; 195 | aspect-ratio: 1; 196 | border-radius: 50%; 197 | border: 8px solid; 198 | border-color: var(--of-text) #121a31; 199 | animation: l1 1s infinite; 200 | padding: 8px; 201 | background: var(--of-text) content-box; 202 | margin: 100px auto; 203 | } 204 | 205 | @keyframes l1 {to{transform: rotate(.5turn)}} -------------------------------------------------------------------------------- /app/overfast_client.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from fastapi import HTTPException, status 3 | 4 | from .cache_manager import CacheManager 5 | from .config import settings 6 | from .helpers import send_discord_webhook_message 7 | from .metaclasses import Singleton 8 | from .overfast_logger import logger 9 | 10 | 11 | class OverFastClient(metaclass=Singleton): 12 | def __init__(self): 13 | self.cache_manager = CacheManager() 14 | self.client = httpx.AsyncClient( 15 | headers={ 16 | "User-Agent": ( 17 | f"OverFastAPI v{settings.app_version} - " 18 | "https://github.com/TeKrop/overfast-api" 19 | ), 20 | "From": "valentin.porchet@proton.me", 21 | }, 22 | http2=True, 23 | timeout=10, 24 | follow_redirects=True, 25 | ) 26 | 27 | async def get(self, url: str, **kwargs) -> httpx.Response: 28 | """Make an HTTP GET request with custom headers and retrieve the result""" 29 | 30 | # First, check if we're being rate limited 31 | self._check_rate_limit() 32 | 33 | # Make the API call 34 | try: 35 | response = await self.client.get(url, **kwargs) 36 | except httpx.TimeoutException as error: 37 | # Sometimes Blizzard takes too much time to give a response (player profiles, etc.) 38 | raise self._blizzard_response_error( 39 | status_code=0, 40 | error="Blizzard took more than 10 seconds to respond, resulting in a timeout", 41 | ) from error 42 | except httpx.RemoteProtocolError as error: 43 | # Sometimes Blizzard sends an invalid response (search players, etc.) 44 | raise self._blizzard_response_error( 45 | status_code=0, 46 | error="Blizzard closed the connection, no data could be retrieved", 47 | ) from error 48 | 49 | logger.debug("OverFast request done !") 50 | 51 | # Make sure we catch HTTP 403 from Blizzard when it happens, 52 | # so we don't make any more call before some amount of time 53 | if response.status_code == status.HTTP_403_FORBIDDEN: 54 | raise self._blizzard_forbidden_error() 55 | 56 | return response 57 | 58 | async def aclose(self) -> None: 59 | """Properly close HTTPX Async Client""" 60 | await self.client.aclose() 61 | 62 | def _check_rate_limit(self) -> None: 63 | """Make sure we're not being rate limited by Blizzard before making 64 | any API call. Else, return an HTTP 429 with Retry-After header. 65 | """ 66 | if self.cache_manager.is_being_rate_limited(): 67 | raise self._too_many_requests_response( 68 | retry_after=self.cache_manager.get_global_rate_limit_remaining_time() 69 | ) 70 | 71 | def blizzard_response_error_from_response( 72 | self, response: httpx.Response 73 | ) -> HTTPException: 74 | """Alias for sending Blizzard error from a request directly""" 75 | return self._blizzard_response_error(response.status_code, response.text) 76 | 77 | @staticmethod 78 | def _blizzard_response_error(status_code: int, error: str) -> HTTPException: 79 | """Retrieve a generic error response when a Blizzard page doesn't load""" 80 | logger.error( 81 | "Received an error from Blizzard. HTTP {} : {}", 82 | status_code, 83 | error, 84 | ) 85 | 86 | return HTTPException( 87 | status_code=status.HTTP_504_GATEWAY_TIMEOUT, 88 | detail=f"Couldn't get Blizzard page (HTTP {status_code} error) : {error}", 89 | ) 90 | 91 | def _blizzard_forbidden_error(self) -> HTTPException: 92 | """Retrieve a generic error response when Blizzard returns forbidden error. 93 | Also prevent further calls to Blizzard for a given amount of time. 94 | """ 95 | 96 | # We have to block future requests to Blizzard, cache the information on Valkey 97 | self.cache_manager.set_global_rate_limit() 98 | 99 | # If Discord Webhook configuration is enabled, send a message to the 100 | # given channel using Discord Webhook URL 101 | if settings.discord_message_on_rate_limit: 102 | send_discord_webhook_message( 103 | "Blizzard Rate Limit reached ! Blocking further calls for " 104 | f"{settings.blizzard_rate_limit_retry_after} seconds..." 105 | ) 106 | 107 | return self._too_many_requests_response( 108 | retry_after=settings.blizzard_rate_limit_retry_after 109 | ) 110 | 111 | @staticmethod 112 | def _too_many_requests_response(retry_after: int) -> HTTPException: 113 | """Generic method to return an HTTP 429 response with Retry-After header""" 114 | return HTTPException( 115 | status_code=status.HTTP_429_TOO_MANY_REQUESTS, 116 | detail=( 117 | "API has been rate limited by Blizzard, please wait for " 118 | f"{retry_after} seconds before retrying" 119 | ), 120 | headers={settings.retry_after_header: str(retry_after)}, 121 | ) 122 | -------------------------------------------------------------------------------- /app/heroes/router.py: -------------------------------------------------------------------------------- 1 | """Heroes endpoints router : heroes list, heroes details, etc.""" 2 | 3 | from typing import Annotated 4 | 5 | from fastapi import APIRouter, Path, Query, Request, Response, status 6 | 7 | from app.enums import Locale, RouteTag 8 | from app.helpers import routes_responses 9 | from app.maps.enums import MapKey 10 | from app.players.enums import ( 11 | CompetitiveDivisionFilter, 12 | PlayerGamemode, 13 | PlayerPlatform, 14 | PlayerRegion, 15 | ) 16 | from app.roles.enums import Role 17 | 18 | from .controllers.get_hero_controller import GetHeroController 19 | from .controllers.get_hero_stats_summary_controller import GetHeroStatsSummaryController 20 | from .controllers.list_heroes_controller import ListHeroesController 21 | from .enums import HeroKey 22 | from .models import ( 23 | BadRequestErrorMessage, 24 | Hero, 25 | HeroParserErrorMessage, 26 | HeroShort, 27 | HeroStatsSummary, 28 | ) 29 | 30 | router = APIRouter() 31 | 32 | 33 | @router.get( 34 | "", 35 | responses=routes_responses, 36 | tags=[RouteTag.HEROES], 37 | summary="Get a list of heroes", 38 | description=( 39 | "Get a list of Overwatch heroes, which can be filtered using roles. " 40 | f"
**Cache TTL : {ListHeroesController.get_human_readable_timeout()}.**" 41 | ), 42 | operation_id="list_heroes", 43 | ) 44 | async def list_heroes( 45 | request: Request, 46 | response: Response, 47 | role: Annotated[Role | None, Query(title="Role filter")] = None, 48 | locale: Annotated[ 49 | Locale, Query(title="Locale to be displayed") 50 | ] = Locale.ENGLISH_US, 51 | ) -> list[HeroShort]: 52 | return await ListHeroesController(request, response).process_request( 53 | role=role, 54 | locale=locale, 55 | ) 56 | 57 | 58 | @router.get( 59 | "/stats", 60 | responses={ 61 | **routes_responses, 62 | status.HTTP_400_BAD_REQUEST: { 63 | "model": BadRequestErrorMessage, 64 | "description": "Bad Request Error", 65 | }, 66 | }, 67 | tags=[RouteTag.HEROES], 68 | summary="Get hero stats", 69 | description=( 70 | "Get hero statistics usage, filtered by platform, region, role, etc." 71 | "Only Role Queue gamemodes are concerned." 72 | f"
**Cache TTL : {GetHeroStatsSummaryController.get_human_readable_timeout()}.**" 73 | ), 74 | operation_id="get_hero_stats", 75 | ) 76 | async def get_hero_stats( 77 | request: Request, 78 | response: Response, 79 | platform: Annotated[ 80 | PlayerPlatform, Query(title="Player platform filter", examples=["pc"]) 81 | ], 82 | gamemode: Annotated[ 83 | PlayerGamemode, 84 | Query( 85 | title="Gamemode", 86 | description=("Filter on a specific gamemode."), 87 | examples=["competitive"], 88 | ), 89 | ], 90 | region: Annotated[ 91 | PlayerRegion, 92 | Query( 93 | title="Region", 94 | description=("Filter on a specific player region."), 95 | examples=["europe"], 96 | ), 97 | ], 98 | role: Annotated[ 99 | Role | None, Query(title="Role filter", examples=["support"]) 100 | ] = None, 101 | map_: Annotated[ 102 | MapKey | None, Query(alias="map", title="Map key filter", examples=["hanaoka"]) 103 | ] = None, 104 | competitive_division: Annotated[ 105 | CompetitiveDivisionFilter | None, 106 | Query( 107 | title="Competitive division filter", 108 | examples=["diamond"], 109 | ), 110 | ] = None, 111 | order_by: Annotated[ 112 | str, 113 | Query( 114 | title="Ordering field and the way it's arranged (asc[ending]/desc[ending])", 115 | pattern=r"^(hero|winrate|pickrate):(asc|desc)$", 116 | ), 117 | ] = "hero:asc", 118 | ) -> list[HeroStatsSummary]: 119 | return await GetHeroStatsSummaryController(request, response).process_request( 120 | platform=platform, 121 | gamemode=gamemode, 122 | region=region, 123 | role=role, 124 | map=map_, 125 | competitive_division=competitive_division, 126 | order_by=order_by, 127 | ) 128 | 129 | 130 | @router.get( 131 | "/{hero_key}", 132 | responses={ 133 | status.HTTP_404_NOT_FOUND: { 134 | "model": HeroParserErrorMessage, 135 | "description": "Hero Not Found", 136 | }, 137 | **routes_responses, 138 | }, 139 | tags=[RouteTag.HEROES], 140 | summary="Get hero data", 141 | description=( 142 | "Get data about an Overwatch hero : description, abilities, story, etc. " 143 | f"
**Cache TTL : {GetHeroController.get_human_readable_timeout()}.**" 144 | ), 145 | operation_id="get_hero", 146 | ) 147 | async def get_hero( 148 | request: Request, 149 | response: Response, 150 | hero_key: Annotated[HeroKey, Path(title="Key name of the hero")], 151 | locale: Annotated[ 152 | Locale, Query(title="Locale to be displayed") 153 | ] = Locale.ENGLISH_US, 154 | ) -> Hero: 155 | return await GetHeroController(request, response).process_request( 156 | hero_key=hero_key, 157 | locale=locale, 158 | ) 159 | --------------------------------------------------------------------------------