├── 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 |
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 |
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 |
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 |
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 |
8 |
--------------------------------------------------------------------------------
/static/gamemodes/payload-race-icon.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------