├── .env.dist ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── app ├── __init__.py ├── cache_manager.py ├── config.py ├── controllers.py ├── decorators.py ├── enums.py ├── exceptions.py ├── gamemodes │ ├── __init__.py │ ├── controllers │ │ ├── __init__.py │ │ └── list_gamemodes_controller.py │ ├── data │ │ └── gamemodes.csv │ ├── enums.py │ ├── models.py │ ├── parsers │ │ ├── __init__.py │ │ └── gamemodes_parser.py │ └── router.py ├── helpers.py ├── heroes │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ └── check_new_hero.py │ ├── controllers │ │ ├── __init__.py │ │ ├── get_hero_controller.py │ │ └── list_heroes_controller.py │ ├── data │ │ └── heroes.csv │ ├── enums.py │ ├── models.py │ ├── parsers │ │ ├── __init__.py │ │ ├── hero_parser.py │ │ ├── heroes_parser.py │ │ └── heroes_stats_parser.py │ └── router.py ├── main.py ├── maps │ ├── __init__.py │ ├── controllers │ │ ├── __init__.py │ │ └── list_maps_controller.py │ ├── data │ │ └── maps.csv │ ├── models.py │ ├── parsers │ │ ├── __init__.py │ │ └── maps_parser.py │ └── router.py ├── metaclasses.py ├── middlewares.py ├── models.py ├── overfast_client.py ├── overfast_logger.py ├── parsers.py ├── players │ ├── __init__.py │ ├── api_examples.py │ ├── controllers │ │ ├── __init__.py │ │ ├── get_player_career_controller.py │ │ ├── get_player_career_stats_controller.py │ │ ├── get_player_stats_summary_controller.py │ │ └── search_players_controller.py │ ├── enums.py │ ├── exceptions.py │ ├── helpers.py │ ├── models.py │ ├── parsers │ │ ├── __init__.py │ │ ├── base_player_parser.py │ │ ├── player_career_parser.py │ │ ├── player_career_stats_parser.py │ │ ├── player_search_parser.py │ │ ├── player_stats_summary_parser.py │ │ └── search_data_parser.py │ └── router.py ├── roles │ ├── __init__.py │ ├── controllers │ │ ├── __init__.py │ │ └── list_roles_controller.py │ ├── enums.py │ ├── helpers.py │ ├── models.py │ ├── parsers │ │ ├── __init__.py │ │ └── roles_parser.py │ └── router.py └── unlocks_manager.py ├── build ├── nginx │ ├── Dockerfile │ ├── entrypoint.sh │ ├── overfast-api.conf.template │ └── redis_handler.lua.template ├── overfast-crontab ├── redis │ ├── Dockerfile │ └── init.sh └── reverse-proxy │ ├── default.conf │ └── nginx.conf ├── docker-compose.yml ├── pyproject.toml ├── static ├── favicon.ico ├── favicon.png ├── gamemodes │ ├── assault-icon.svg │ ├── assault.avif │ ├── capture-the-flag-icon.svg │ ├── capture-the-flag.avif │ ├── clash-icon.svg │ ├── clash.avif │ ├── control-icon.svg │ ├── control.avif │ ├── deathmatch-icon.svg │ ├── deathmatch.avif │ ├── elimination-icon.svg │ ├── elimination.avif │ ├── escort-icon.svg │ ├── escort.avif │ ├── flashpoint-icon.svg │ ├── flashpoint.avif │ ├── hybrid-icon.svg │ ├── hybrid.avif │ ├── practice-range-icon.svg │ ├── practice-range.avif │ ├── push-icon.svg │ ├── push.avif │ ├── team-deathmatch-icon.svg │ └── team-deathmatch.avif ├── logo.png └── maps │ ├── antarctic_peninsula.jpg │ ├── anubis.jpg │ ├── arena_victoriae.jpg │ ├── ayutthaya.jpg │ ├── black_forest.jpg │ ├── blizzard_world.jpg │ ├── busan.jpg │ ├── castillo.jpg │ ├── chateau_guillard.jpg │ ├── circuit_royal.jpg │ ├── colosseo.jpg │ ├── dorado.jpg │ ├── ecopoint_antarctica.jpg │ ├── eichenwalde.jpg │ ├── esperanca.jpg │ ├── gibraltar.jpg │ ├── gogadoro.jpg │ ├── hanamura.jpg │ ├── hanaoka.jpg │ ├── havana.jpg │ ├── hollywood.jpg │ ├── horizon.jpg │ ├── ilios.jpg │ ├── junkertown.jpg │ ├── kanezaka.jpg │ ├── kings_row.jpg │ ├── lijiang.jpg │ ├── malevento.jpg │ ├── midtown.jpg │ ├── necropolis.jpg │ ├── nepal.jpg │ ├── new_junk_city.jpg │ ├── new_queen_street.jpg │ ├── numbani.jpg │ ├── oasis.jpg │ ├── paraiso.jpg │ ├── paris.jpg │ ├── petra.jpg │ ├── place_lacroix.jpg │ ├── practice_range.jpg │ ├── redwood_dam.jpg │ ├── rialto.jpg │ ├── route_66.jpg │ ├── runasapi.jpg │ ├── samoa.jpg │ ├── shambali.jpg │ ├── suravasa.jpg │ ├── throne_of_anubis.jpg │ └── volskaya.jpg ├── tests ├── __init__.py ├── conftest.py ├── fixtures │ ├── html │ │ ├── heroes.html │ │ ├── heroes │ │ │ ├── ana.html │ │ │ ├── ashe.html │ │ │ ├── baptiste.html │ │ │ ├── bastion.html │ │ │ ├── brigitte.html │ │ │ ├── cassidy.html │ │ │ ├── doomfist.html │ │ │ ├── dva.html │ │ │ ├── echo.html │ │ │ ├── freja.html │ │ │ ├── genji.html │ │ │ ├── hanzo.html │ │ │ ├── hazard.html │ │ │ ├── illari.html │ │ │ ├── junker-queen.html │ │ │ ├── junkrat.html │ │ │ ├── juno.html │ │ │ ├── kiriko.html │ │ │ ├── lifeweaver.html │ │ │ ├── lucio.html │ │ │ ├── mauga.html │ │ │ ├── mei.html │ │ │ ├── mercy.html │ │ │ ├── moira.html │ │ │ ├── orisa.html │ │ │ ├── pharah.html │ │ │ ├── ramattra.html │ │ │ ├── reaper.html │ │ │ ├── reinhardt.html │ │ │ ├── roadhog.html │ │ │ ├── sigma.html │ │ │ ├── sojourn.html │ │ │ ├── soldier-76.html │ │ │ ├── sombra.html │ │ │ ├── symmetra.html │ │ │ ├── torbjorn.html │ │ │ ├── tracer.html │ │ │ ├── unknown-hero.html │ │ │ ├── venture.html │ │ │ ├── widowmaker.html │ │ │ ├── winston.html │ │ │ ├── wrecking-ball.html │ │ │ ├── zarya.html │ │ │ └── zenyatta.html │ │ ├── home.html │ │ └── players │ │ │ ├── JohnV1-1190.html │ │ │ ├── KIRIKO-12460.html │ │ │ ├── TeKrop-2217.html │ │ │ └── Unknown-1234.html │ └── json │ │ ├── blizzard_unlock_data.json │ │ ├── formatted_search_data.json │ │ └── search_players_blizzard_result.json ├── gamemodes │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_gamemodes_parser.py │ └── test_gamemodes_route.py ├── helpers.py ├── heroes │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ └── test_check_new_hero.py │ ├── conftest.py │ ├── controllers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_heroes_controllers.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_hero_parser.py │ │ └── test_heroes_parser.py │ ├── test_hero_routes.py │ └── test_heroes_route.py ├── maps │ ├── __init__.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_maps_parser.py │ └── test_maps_route.py ├── players │ ├── __init__.py │ ├── conftest.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_player_career_parser.py │ │ ├── test_player_career_stats_parser.py │ │ └── test_player_stats_summary_parser.py │ ├── test_player_career_route.py │ ├── test_player_stats_route.py │ ├── test_player_stats_summary_route.py │ ├── test_player_summary_route.py │ ├── test_players_helpers.py │ └── test_search_players_route.py ├── roles │ ├── __init__.py │ ├── conftest.py │ ├── parsers │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_roles_parser.py │ └── test_roles_route.py ├── test_cache_manager.py ├── test_decorators.py ├── test_documentation_route.py ├── test_helpers.py ├── test_update_test_fixtures.py └── update_test_fixtures.py └── uv.lock /.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 | 11 | # Rate limiting 12 | RETRY_AFTER_HEADER=Retry-After 13 | BLIZZARD_RATE_LIMIT_RETRY_AFTER=5 14 | RATE_LIMIT_PER_SECOND_PER_IP=30 15 | RATE_LIMIT_PER_IP_BURST=5 16 | MAX_CONNECTIONS_PER_IP=10 17 | 18 | # Redis 19 | REDIS_HOST=redis 20 | REDIS_PORT=6379 21 | REDIS_MEMORY_LIMIT=1gb 22 | 23 | # Cache configuration 24 | CACHE_TTL_HEADER=X-Cache-TTL 25 | PLAYER_CACHE_TIMEOUT=86400 26 | HEROES_PATH_CACHE_TIMEOUT=86400 27 | HERO_PATH_CACHE_TIMEOUT=86400 28 | CSV_CACHE_TIMEOUT=86400 29 | CAREER_PATH_CACHE_TIMEOUT=600 30 | SEARCH_ACCOUNT_PATH_CACHE_TIMEOUT=600 31 | 32 | # Critical error Discord webhook 33 | DISCORD_WEBHOOK_ENABLED=false 34 | DISCORD_WEBHOOK_URL="" 35 | DISCORD_MESSAGE_ON_RATE_LIMIT=false -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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.13"] 17 | uv-version: ["0.6.10"] 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 | -------------------------------------------------------------------------------- /.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 dump 128 | dump.rdb 129 | 130 | # Loguru gzipped logs 131 | *.log.gz 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.13 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: v0.9.4 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 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | valentin.porchet@proton.me. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /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). -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build arguments 2 | ARG PYTHON_VERSION=3.13 3 | ARG UV_VERSION=0.6.10 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 | # Copy code and static folders 21 | COPY ./app /code/app 22 | COPY ./static /code/static 23 | 24 | # Install the project 25 | RUN uv sync --frozen --no-cache --no-dev 26 | 27 | # Copy crontabs file and make it executable 28 | COPY ./build/overfast-crontab /etc/crontabs/root 29 | RUN chmod +x /etc/crontabs/root 30 | 31 | # For dev image, copy the tests and install necessary dependencies 32 | FROM main as dev 33 | RUN uv sync --frozen --no-cache 34 | COPY ./tests /code/tests -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 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 | -------------------------------------------------------------------------------- /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 | --publish 8000:8000 \ 19 | --rm \ 20 | app 21 | 22 | help: ## Show this help message 23 | @echo "Usage: make " 24 | @echo "" 25 | @echo "${CYAN}Commands:${RESET}" 26 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?##/ {printf " ${GREEN}%-10s${RESET} : ${YELLOW}%s${RESET}\n", $$1, $$2}' $(MAKEFILE_LIST) 27 | 28 | build: ## Build project images 29 | @echo "Building OverFastAPI (dev mode)..." 30 | BUILD_TARGET="dev" $(DOCKER_COMPOSE) build 31 | 32 | start: ## Run OverFastAPI application (dev or testing mode) 33 | ifdef TESTING_MODE 34 | @echo "Launching OverFastAPI (testing mode with reverse proxy)..." 35 | $(DOCKER_COMPOSE) --profile testing up -d 36 | else 37 | @echo "Launching OverFastAPI (dev mode with autoreload)..." 38 | $(DOCKER_RUN) uv run fastapi dev app/main.py --host 0.0.0.0 39 | endif 40 | 41 | lint: ## Run linter 42 | @echo "Running linter..." 43 | $(DOCKER_RUN) uv run ruff check --fix --exit-non-zero-on-fix 44 | 45 | format: ## Run formatter 46 | @echo "Running formatter..." 47 | $(DOCKER_RUN) uv run 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 -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/__init__.py -------------------------------------------------------------------------------- /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 Redis 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 | -------------------------------------------------------------------------------- /app/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators module""" 2 | 3 | import time 4 | from collections.abc import Callable 5 | from functools import wraps 6 | 7 | from .overfast_logger import logger 8 | 9 | 10 | def rate_limited(max_calls: int, interval: int): 11 | """Put a rate limit on function call using specified parameters : 12 | X **max_calls** per *interval* seconds. It prevents too many calls of a 13 | given method with the exact same parameters, for example the Discord 14 | webhook if there is a critical parsing error. 15 | """ 16 | 17 | def decorator(func: Callable): 18 | call_history = {} 19 | 20 | @wraps(func) 21 | def wrapper(*args, **kwargs): 22 | # Define a unique key by using given parameters 23 | key = (args, tuple(kwargs.items())) 24 | now = time.time() 25 | 26 | # If the key is not already in history, insert it and make the call 27 | if key not in call_history: 28 | call_history[key] = [now] 29 | return func(*args, **kwargs) 30 | 31 | # Else, update the call history by removing expired limits 32 | timestamps = call_history[key] 33 | timestamps[:] = [t for t in timestamps if t >= now - interval] 34 | 35 | # If there is no limit anymore or if the max 36 | # number of calls hasn't been reached yet, continue 37 | if len(timestamps) < max_calls: 38 | timestamps.append(now) 39 | return func(*args, **kwargs) 40 | else: 41 | # Else the function is being rate limited 42 | logger.warning( 43 | "Rate limit exceeded for {} with the same " 44 | "parameters. Try again later.", 45 | func.__name__, 46 | ) 47 | return None 48 | 49 | return wrapper 50 | 51 | return decorator 52 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /app/gamemodes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/gamemodes/__init__.py -------------------------------------------------------------------------------- /app/gamemodes/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/gamemodes/controllers/__init__.py -------------------------------------------------------------------------------- /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/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 | push,Push,Teams battle to take control of a robot and push it toward the enemy base. 12 | team-deathmatch,Team Deathmatch,Team up and triumph over your enemies by scoring the most kills. 13 | practice-range,Practice Range,"Learn the basics, practice and test your settings." -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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://blz-contentstack-images.akamaized.net/v3/assets/blt9c12f249ac15c7ec/blt054b513cd6e95acf/62fd5b4a8972f93d1e325243/Push.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://blz-contentstack-images.akamaized.net/v3/assets/blt9c12f249ac15c7ec/blt93eefb6e91347639/62fc2d9eda42240856c1459c/Toronto_Push.jpg", 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /app/gamemodes/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/gamemodes/parsers/__init__.py -------------------------------------------------------------------------------- /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/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/helpers.py: -------------------------------------------------------------------------------- 1 | """Parser Helpers module""" 2 | 3 | import csv 4 | from functools import cache 5 | from pathlib import Path 6 | 7 | import httpx 8 | from fastapi import HTTPException, status 9 | 10 | from .config import settings 11 | from .decorators import rate_limited 12 | from .models import ( 13 | BlizzardErrorMessage, 14 | InternalServerErrorMessage, 15 | RateLimitErrorMessage, 16 | ) 17 | from .overfast_logger import logger 18 | 19 | # Typical routes responses to return 20 | success_responses = { 21 | status.HTTP_200_OK: { 22 | "description": "Successful Response", 23 | "headers": { 24 | settings.cache_ttl_header: { 25 | "description": "The TTL value for the cached response, in seconds", 26 | "schema": { 27 | "type": "string", 28 | "example": "600", 29 | }, 30 | }, 31 | }, 32 | }, 33 | } 34 | 35 | routes_responses = { 36 | **success_responses, 37 | status.HTTP_429_TOO_MANY_REQUESTS: { 38 | "model": RateLimitErrorMessage, 39 | "description": "Rate Limit Error", 40 | "headers": { 41 | settings.retry_after_header: { 42 | "description": "Indicates how long to wait before making a new request", 43 | "schema": { 44 | "type": "string", 45 | "example": "5", 46 | }, 47 | } 48 | }, 49 | }, 50 | status.HTTP_500_INTERNAL_SERVER_ERROR: { 51 | "model": InternalServerErrorMessage, 52 | "description": "Internal Server Error", 53 | }, 54 | status.HTTP_504_GATEWAY_TIMEOUT: { 55 | "model": BlizzardErrorMessage, 56 | "description": "Blizzard Server Error", 57 | }, 58 | } 59 | 60 | 61 | def overfast_internal_error(url: str, error: Exception) -> HTTPException: 62 | """Returns an Internal Server Error. Also log it and eventually send 63 | a Discord notification via a webhook if configured. 64 | """ 65 | 66 | # Log the critical error 67 | logger.critical( 68 | "Internal server error for URL {} : {}", 69 | url, 70 | str(error), 71 | ) 72 | 73 | # If we're using a profiler, it means we're debugging, raise the error 74 | # directly in order to have proper backtrace in logs 75 | if settings.profiler: 76 | raise error # pragma: no cover 77 | 78 | # Else, send a message to the given channel using Discord Webhook URL 79 | send_discord_webhook_message( 80 | f"* **URL** : {url}\n" 81 | f"* **Error type** : {type(error).__name__}\n" 82 | f"* **Message** : {error}", 83 | ) 84 | 85 | return HTTPException( 86 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 87 | detail=settings.internal_server_error_message, 88 | ) 89 | 90 | 91 | @rate_limited(max_calls=1, interval=1800) 92 | def send_discord_webhook_message(message: str) -> httpx.Response | None: 93 | """Helper method for sending a Discord webhook message. It's limited to 94 | one call per 30 minutes with the same parameters.""" 95 | if not settings.discord_webhook_enabled: 96 | logger.error(message) 97 | return None 98 | 99 | return httpx.post( # pragma: no cover 100 | settings.discord_webhook_url, data={"content": message}, timeout=10 101 | ) 102 | 103 | 104 | @cache 105 | def read_csv_data_file(filename: str) -> list[dict[str, str]]: 106 | """Helper method for obtaining CSV DictReader from a path""" 107 | with Path(f"{Path.cwd()}/app/{filename}/data/{filename}.csv").open( 108 | encoding="utf-8" 109 | ) as csv_file: 110 | return list(csv.DictReader(csv_file, delimiter=",")) 111 | 112 | 113 | @cache 114 | def get_human_readable_duration(duration: int) -> str: 115 | # Define the time units 116 | days, remainder = divmod(duration, 86400) 117 | hours, remainder = divmod(remainder, 3600) 118 | minutes, _ = divmod(remainder, 60) 119 | 120 | # Build the human-readable string 121 | duration_parts = [] 122 | if days > 0: 123 | duration_parts.append(f"{days} day{'s' if days > 1 else ''}") 124 | if hours > 0: 125 | duration_parts.append(f"{hours} hour{'s' if hours > 1 else ''}") 126 | if minutes > 0: 127 | duration_parts.append(f"{minutes} minute{'s' if minutes > 1 else ''}") 128 | 129 | return ", ".join(duration_parts) 130 | -------------------------------------------------------------------------------- /app/heroes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/heroes/__init__.py -------------------------------------------------------------------------------- /app/heroes/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/heroes/commands/__init__.py -------------------------------------------------------------------------------- /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/heroes/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/heroes/controllers/__init__.py -------------------------------------------------------------------------------- /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/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/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,275,0,0 8 | dva,D.Va,tank,350,375,0 9 | doomfist,Doomfist,tank,525,0,0 10 | echo,Echo,damage,150,0,75 11 | freja,Freja,damage,250,0,0 12 | genji,Genji,damage,250,0,0 13 | hazard,Hazard,tank,525,0,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,250,0,0 19 | kiriko,Kiriko,support,250,0,0 20 | lifeweaver,Lifeweaver,support,225,0,50 21 | lucio,Lúcio,support,250,0,0 22 | mauga,Mauga,tank,525,200,0 23 | mei,Mei,damage,300,0,0 24 | mercy,Mercy,support,250,0,0 25 | moira,Moira,support,250,0,0 26 | orisa,Orisa,tank,325,300,0 27 | pharah,Pharah,damage,225,0,0 28 | ramattra,Ramattra,tank,400,75,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,250,0,0 34 | soldier-76,Soldier: 76,damage,250,0,0 35 | sombra,Sombra,damage,250,0,0 36 | symmetra,Symmetra,damage,100,0,150 37 | torbjorn,Torbjörn,damage,225,50,0 38 | tracer,Tracer,damage,175,0,0 39 | venture,Venture,damage,250,0,0 40 | widowmaker,Widowmaker,damage,225,0,0 41 | winston,Winston,tank,375,250,0 42 | wrecking-ball,Wrecking Ball,tank,450,175,150 43 | zarya,Zarya,tank,325,0,225 44 | zenyatta,Zenyatta,support,75,0,175 45 | -------------------------------------------------------------------------------- /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/heroes/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/heroes/parsers/__init__.py -------------------------------------------------------------------------------- /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["data-hero-id"], 17 | "name": hero.attributes["hero-name"], 18 | "portrait": hero.css_first("blz-image").attributes["src"], 19 | "role": hero.attributes["data-role"], 20 | } 21 | for hero in self.root_tag.css("blz-media-gallery blz-hero-card") 22 | ], 23 | key=lambda hero: hero["key"], 24 | ) 25 | 26 | def filter_request_using_query(self, **kwargs) -> list[dict]: 27 | role = kwargs.get("role") 28 | return ( 29 | self.data 30 | if not role 31 | else [hero_dict for hero_dict in self.data if hero_dict["role"] == role] 32 | ) 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.roles.enums import Role 10 | 11 | from .controllers.get_hero_controller import GetHeroController 12 | from .controllers.list_heroes_controller import ListHeroesController 13 | from .enums import HeroKey 14 | from .models import Hero, HeroParserErrorMessage, HeroShort 15 | 16 | router = APIRouter() 17 | 18 | 19 | @router.get( 20 | "", 21 | responses=routes_responses, 22 | tags=[RouteTag.HEROES], 23 | summary="Get a list of heroes", 24 | description=( 25 | "Get a list of Overwatch heroes, which can be filtered using roles. " 26 | f"
**Cache TTL : {ListHeroesController.get_human_readable_timeout()}.**" 27 | ), 28 | operation_id="list_heroes", 29 | ) 30 | async def list_heroes( 31 | request: Request, 32 | response: Response, 33 | role: Annotated[Role | None, Query(title="Role filter")] = None, 34 | locale: Annotated[ 35 | Locale, Query(title="Locale to be displayed") 36 | ] = Locale.ENGLISH_US, 37 | ) -> list[HeroShort]: 38 | return await ListHeroesController(request, response).process_request( 39 | role=role, 40 | locale=locale, 41 | ) 42 | 43 | 44 | @router.get( 45 | "/{hero_key}", 46 | responses={ 47 | status.HTTP_404_NOT_FOUND: { 48 | "model": HeroParserErrorMessage, 49 | "description": "Hero Not Found", 50 | }, 51 | **routes_responses, 52 | }, 53 | tags=[RouteTag.HEROES], 54 | summary="Get hero data", 55 | description=( 56 | "Get data about an Overwatch hero : description, abilities, story, etc. " 57 | f"
**Cache TTL : {GetHeroController.get_human_readable_timeout()}.**" 58 | ), 59 | operation_id="get_hero", 60 | ) 61 | async def get_hero( 62 | request: Request, 63 | response: Response, 64 | hero_key: Annotated[HeroKey, Path(title="Key name of the hero")], 65 | locale: Annotated[ 66 | Locale, Query(title="Locale to be displayed") 67 | ] = Locale.ENGLISH_US, 68 | ) -> Hero: 69 | return await GetHeroController(request, response).process_request( 70 | hero_key=hero_key, 71 | locale=locale, 72 | ) 73 | -------------------------------------------------------------------------------- /app/maps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/maps/__init__.py -------------------------------------------------------------------------------- /app/maps/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/maps/controllers/__init__.py -------------------------------------------------------------------------------- /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/maps/data/maps.csv: -------------------------------------------------------------------------------- 1 | key,name,gamemodes,location,country_code 2 | hanamura,Hanamura,assault,"Tokyo, Japan",JP 3 | horizon,Horizon Lunar Colony,assault,Earth's moon, 4 | paris,Paris,assault,"Paris, France",FR 5 | anubis,Temple of Anubis,assault,"Giza Plateau, Egypt",EG 6 | volskaya,Volskaya Industries,assault,"St. Petersburg, Russia",RU 7 | ayutthaya,Ayutthaya,capture-the-flag,Thailand,TH 8 | busan,Busan,control,South Korea,KR 9 | nepal,Nepal,control,Nepal,NP 10 | ilios,Ilios,control,Greece,GR 11 | oasis,Oasis,control,Iraq,IQ 12 | lijiang,Lijiang Tower,control,China,CN 13 | chateau_guillard,Château Guillard,"deathmatch,team-deathmatch","Annecy, France",FR 14 | kanezaka,Kanezaka,"deathmatch,team-deathmatch","Tokyo, Japan",JP 15 | malevento,Malevento,"deathmatch,team-deathmatch",Italy,IT 16 | petra,Petra,"deathmatch,team-deathmatch",Southern Jordan,JO 17 | black_forest,Black Forest,elimination,Germany,DE 18 | castillo,Castillo,elimination,Mexico,MX 19 | ecopoint_antarctica,Ecopoint: Antarctica,elimination,Antarctica,AQ 20 | necropolis,Necropolis,elimination,Egypt,EG 21 | circuit_royal,Circuit Royal,escort,"Monte Carlo, Monaco",MC 22 | dorado,Dorado,escort,Mexico,MX 23 | route_66,Route 66,escort,"Albuquerque, New Mexico, United States",US 24 | junkertown,Junkertown,escort,Central Australia,AU 25 | rialto,Rialto,escort,"Venice, Italy",IT 26 | havana,Havana,escort,"Havana, Cuba",CU 27 | gibraltar,Watchpoint: Gibraltar,escort,Gibraltar,GI 28 | shambali,Shambali Monastery,escort,Nepal,NP 29 | blizzard_world,Blizzard World,hybrid,"Irvine, California, United States",US 30 | numbani,Numbani,hybrid,Numbani (near Nigeria), 31 | hollywood,Hollywood,hybrid,"Los Angeles, United States",US 32 | eichenwalde,Eichenwalde,hybrid,"Stuttgart, Germany",DE 33 | kings_row,King’s Row,hybrid,"London, United Kingdom",UK 34 | midtown,Midtown,hybrid,"New York, United States",US 35 | paraiso,Paraíso,hybrid,"Rio de Janeiro, Brazil",BR 36 | colosseo,Colosseo,push,"Rome, Italy",IT 37 | esperanca,Esperança,push,Portugal,PT 38 | new_queen_street,New Queen Street,push,"Toronto, Canada",CA 39 | antarctic_peninsula,Antarctic Peninsula,control,Antarctica,AQ 40 | new_junk_city,New Junk City,flashpoint,Central Australia,AU 41 | suravasa,Suravasa,flashpoint,India,IN 42 | samoa,Samoa,control,Samoa,WS 43 | runasapi,Runasapi,push,Peru,PE 44 | hanaoka,Hanaoka,clash,"Tokyo, Japan",JP 45 | throne_of_anubis,Throne of Anubis,clash,"Giza Plateau, Egypt",EG 46 | gogadoro,Gogadoro,control,"Busan, South Korea",KR 47 | place_lacroix,Place Lacroix,push,"Paris, France",FR 48 | redwood_dam,Redwood Dam,push,Gibraltar,GI 49 | arena_victoriae,Arena Victoriae,control,"Colosseo, Rome, Italy",IT 50 | practice_range,Practice Range,practice-range,Swiss HQ,CH -------------------------------------------------------------------------------- /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 | 8 | class Map(BaseModel): 9 | name: str = Field(..., description="Name of the map", examples=["Hanamura"]) 10 | screenshot: HttpUrl = Field( 11 | ..., 12 | description="Screenshot of the map", 13 | examples=["https://overfast-api.tekrop.fr/static/maps/hanamura.jpg"], 14 | ) 15 | gamemodes: list[MapGamemode] = Field( 16 | ..., 17 | description="Main gamemodes on which the map is playable", 18 | ) 19 | location: str = Field( 20 | ..., 21 | description="Location of the map", 22 | examples=["Tokyo, Japan"], 23 | ) 24 | country_code: str | None = Field( 25 | ..., 26 | min_length=2, 27 | max_length=2, 28 | description=( 29 | "Country Code of the location of the map. If not defined, it's null." 30 | ), 31 | examples=["JP"], 32 | ) 33 | -------------------------------------------------------------------------------- /app/maps/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/maps/parsers/__init__.py -------------------------------------------------------------------------------- /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 | "name": map_dict["name"], 15 | "screenshot": self.get_static_url(map_dict["key"]), 16 | "gamemodes": map_dict["gamemodes"].split(","), 17 | "location": map_dict["location"], 18 | "country_code": map_dict.get("country_code") or None, 19 | } 20 | for map_dict in self.csv_data 21 | ] 22 | 23 | def filter_request_using_query(self, **kwargs) -> list: 24 | gamemode = kwargs.get("gamemode") 25 | return ( 26 | self.data 27 | if not gamemode 28 | else [ 29 | map_dict for map_dict in self.data if gamemode in map_dict["gamemodes"] 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 Redis 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/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 pathlib import Path 6 | from typing import ClassVar 7 | 8 | from loguru import logger as loguru_logger 9 | 10 | from .config import settings 11 | 12 | 13 | class InterceptHandler(logging.Handler): 14 | """InterceptionHandler class used to intercept python logs in order 15 | to transform them into loguru logs. 16 | """ 17 | 18 | loglevel_mapping: ClassVar[dict] = { 19 | 50: "CRITICAL", 20 | 40: "ERROR", 21 | 30: "WARNING", 22 | 20: "INFO", 23 | 10: "DEBUG", 24 | 0: "NOTSET", 25 | } 26 | 27 | def emit(self, record): # pragma: no cover 28 | try: 29 | level = logger.level(record.levelname).name 30 | except AttributeError: 31 | level = self.loglevel_mapping[record.levelno] 32 | 33 | frame, depth = logging.currentframe(), 2 34 | while frame.f_code.co_filename == logging.__file__: 35 | frame = frame.f_back 36 | depth += 1 37 | 38 | logger.opt(depth=depth, exception=record.exc_info).log( 39 | level, 40 | record.getMessage(), 41 | ) 42 | 43 | 44 | class OverFastLogger: 45 | @classmethod 46 | def make_logger(cls): 47 | return cls.customize_logging( 48 | f"{settings.logs_root_path}/access.log", 49 | level=settings.log_level, 50 | rotation="1 day", 51 | retention="1 year", 52 | compression="gz", 53 | log_format=( 54 | "{time:YYYY-MM-DD HH:mm:ss.SSS} | " 55 | "{level: <8} | " 56 | "{name} - {message}" 57 | ), 58 | ) 59 | 60 | @classmethod 61 | def customize_logging( 62 | cls, 63 | filepath: Path, 64 | level: str, 65 | rotation: str, 66 | retention: str, 67 | compression: str, 68 | log_format: str, 69 | ): 70 | loguru_logger.remove() 71 | loguru_logger.add( 72 | sys.stdout, 73 | enqueue=True, 74 | backtrace=True, 75 | level=level.upper(), 76 | format=log_format, 77 | ) 78 | loguru_logger.add( 79 | str(filepath), 80 | rotation=rotation, 81 | retention=retention, 82 | compression=compression, 83 | enqueue=True, 84 | backtrace=True, 85 | level=level.upper(), 86 | format=log_format, 87 | ) 88 | logging.basicConfig(handlers=[InterceptHandler()], level=0) 89 | logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] 90 | for _log in ("uvicorn", "uvicorn.error", "fastapi"): 91 | _logger = logging.getLogger(_log) 92 | _logger.handlers = [InterceptHandler()] 93 | 94 | return loguru_logger.bind(method=None) 95 | 96 | 97 | # Instanciate generic logger for all the app 98 | logger = OverFastLogger.make_logger() 99 | -------------------------------------------------------------------------------- /app/players/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/players/__init__.py -------------------------------------------------------------------------------- /app/players/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/players/controllers/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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/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 UnlockDataType(StrEnum): 92 | NAMECARD = "namecard" 93 | PORTRAIT = "portrait" 94 | TITLE = "title" 95 | 96 | # Special value to only retrieve last_updated_at value 97 | LAST_UPDATED_AT = "lastUpdatedAt" 98 | 99 | # Special value to retrieve all the player data from search endpoint 100 | SUMMARY = "summary" 101 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/players/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/players/parsers/__init__.py -------------------------------------------------------------------------------- /app/players/parsers/base_player_parser.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from fastapi import status 3 | 4 | from app.exceptions import ParserBlizzardError 5 | from app.overfast_logger import logger 6 | from app.parsers import HTMLParser 7 | from app.players.parsers.search_data_parser import SearchDataParser 8 | 9 | 10 | class BasePlayerParser(HTMLParser): 11 | def __init__(self, **kwargs): 12 | super().__init__(**kwargs) 13 | self.player_id = kwargs.get("player_id") 14 | 15 | # Player Data is made of two sets of data : 16 | # - summary, retrieve from players search endpoint 17 | # - profile, gzipped HTML data from player profile page 18 | self.player_data = {"summary": None, "profile": None} 19 | 20 | def get_blizzard_url(self, **kwargs) -> str: 21 | return f"{super().get_blizzard_url(**kwargs)}/{kwargs.get('player_id')}/" 22 | 23 | def store_response_data(self, response: httpx.Response) -> None: 24 | """Store HTML data in player_data to save for Player Cache""" 25 | super().store_response_data(response) 26 | self.player_data["profile"] = response.text 27 | 28 | async def parse(self) -> None: 29 | """Main parsing method for player profile routes""" 30 | 31 | # Check if we have up-to-date data in the Player Cache 32 | logger.info("Retrieving Player Summary...") 33 | self.player_data["summary"] = await self.__retrieve_player_summary_data() 34 | 35 | # If the player doesn't exist, summary will be empty, raise associated error 36 | if not self.player_data["summary"]: 37 | raise ParserBlizzardError( 38 | status_code=status.HTTP_404_NOT_FOUND, 39 | message="Player not found", 40 | ) 41 | 42 | logger.info("Checking Player Cache...") 43 | player_cache = self.cache_manager.get_player_cache(self.player_id) 44 | if ( 45 | player_cache is not None 46 | and player_cache["summary"]["lastUpdated"] 47 | == self.player_data["summary"]["lastUpdated"] 48 | ): 49 | logger.info("Player Cache found and up-to-date, using it") 50 | self.create_parser_tag(player_cache["profile"]) 51 | await self.parse_response_data() 52 | return 53 | 54 | # Data is not in Player Cache or not up-to-date, 55 | # we're retrieving data from Blizzard pages 56 | logger.info("Player Cache not found or not up-to-date, calling Blizzard") 57 | 58 | # Update URL with player summary URL 59 | self.blizzard_url = self.get_blizzard_url( 60 | player_id=self.player_data["summary"]["url"] 61 | ) 62 | await super().parse() 63 | 64 | # Update the Player Cache 65 | self.cache_manager.update_player_cache(self.player_id, self.player_data) 66 | 67 | async def __retrieve_player_summary_data(self) -> dict | None: 68 | """Call Blizzard search page with user name to 69 | check last_updated_at and retrieve unlock values 70 | """ 71 | player_summary_parser = SearchDataParser(player_id=self.player_id) 72 | await player_summary_parser.parse() 73 | return player_summary_parser.data 74 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/players/parsers/player_search_parser.py: -------------------------------------------------------------------------------- 1 | """Player stats summary Parser module""" 2 | 3 | from collections.abc import Iterable 4 | 5 | from app.config import settings 6 | from app.overfast_logger import logger 7 | from app.parsers import JSONParser 8 | from app.unlocks_manager import UnlocksManager 9 | 10 | from ..helpers import get_player_title 11 | 12 | 13 | class PlayerSearchParser(JSONParser): 14 | """Overwatch player search Parser class""" 15 | 16 | root_path = settings.search_account_path 17 | 18 | def __init__(self, **kwargs): 19 | super().__init__(**kwargs) 20 | self.search_nickname = kwargs["name"] 21 | self.order_by = kwargs.get("order_by") 22 | self.offset = kwargs.get("offset") 23 | self.limit = kwargs.get("limit") 24 | self.unlocks_manager = UnlocksManager() 25 | 26 | def get_blizzard_url(self, **kwargs) -> str: 27 | """URL used when requesting data to Blizzard.""" 28 | search_name = kwargs["name"].split("-", 1)[0] 29 | return f"{super().get_blizzard_url(**kwargs)}/{search_name}/" 30 | 31 | async def parse_data(self) -> dict: 32 | # If provided search nickname is a battletag, filter the list 33 | players = self.filter_players() 34 | 35 | # Transform into PlayerSearchResult format 36 | logger.info("Applying transformation..") 37 | players = await self.apply_transformations(players) 38 | 39 | # Apply ordering 40 | logger.info("Applying ordering..") 41 | players = self.apply_ordering(players) 42 | 43 | players_list = { 44 | "total": len(players), 45 | "results": players[self.offset : self.offset + self.limit], 46 | } 47 | 48 | logger.info("Done ! Returning players list...") 49 | return players_list 50 | 51 | def filter_players(self) -> list[dict]: 52 | """Filter players before transforming. If provided nickname is a battletag, 53 | filter results by battle tags before returning them. 54 | """ 55 | if "-" not in self.search_nickname: 56 | return self.json_data 57 | 58 | battletag = self.search_nickname.replace("-", "#") 59 | return [player for player in self.json_data if player["battleTag"] == battletag] 60 | 61 | async def apply_transformations(self, players: Iterable[dict]) -> list[dict]: 62 | """Apply transformations to found players in order to return the data 63 | in the OverFast API format. We'll also retrieve some data from parsers. 64 | """ 65 | transformed_players = [] 66 | 67 | # Retrieve and cache Unlock IDs 68 | unlock_ids = self.__retrieve_unlock_ids(players) 69 | await self.unlocks_manager.cache_values(unlock_ids) 70 | 71 | for player in players: 72 | player_id = player["battleTag"].replace("#", "-") 73 | 74 | transformed_players.append( 75 | { 76 | "player_id": player_id, 77 | "name": player["battleTag"], 78 | "avatar": self.unlocks_manager.get(player["portrait"]), 79 | "namecard": self.unlocks_manager.get(player["namecard"]), 80 | "title": get_player_title( 81 | self.unlocks_manager.get(player["title"]) 82 | ), 83 | "career_url": f"{settings.app_base_url}/players/{player_id}", 84 | "blizzard_id": player["url"], 85 | "last_updated_at": player["lastUpdated"], 86 | }, 87 | ) 88 | return transformed_players 89 | 90 | def apply_ordering(self, players: list[dict]) -> list[dict]: 91 | """Apply the given ordering to the list of found players.""" 92 | order_field, order_arrangement = self.order_by.split(":") 93 | players.sort( 94 | key=lambda player: player[order_field], 95 | reverse=order_arrangement == "desc", 96 | ) 97 | return players 98 | 99 | def __retrieve_unlock_ids(self, players: list[dict]) -> set[str]: 100 | return {player[key] for player in players for key in settings.unlock_keys} 101 | -------------------------------------------------------------------------------- /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 | from app.unlocks_manager import UnlocksManager 7 | 8 | 9 | class SearchDataParser(JSONParser): 10 | """Static Data Parser class""" 11 | 12 | root_path = settings.search_account_path 13 | 14 | def __init__(self, **kwargs): 15 | super().__init__(**kwargs) 16 | self.player_id = kwargs.get("player_id") 17 | self.unlocks_manager = UnlocksManager() 18 | 19 | async def parse_data(self) -> dict: 20 | # We'll use the battletag for searching 21 | player_battletag = self.player_id.replace("-", "#") 22 | 23 | # Find the right player 24 | try: 25 | player_data = next( 26 | player 27 | for player in self.json_data 28 | if player["battleTag"] == player_battletag 29 | ) 30 | except StopIteration: 31 | # We didn't find the player, return nothing 32 | logger.warning( 33 | "Player {} not found in search results, couldn't retrieve data", 34 | self.player_id, 35 | ) 36 | return {} 37 | 38 | # Once we found the player, add unlock values in data (avatar, namecard, title) 39 | return await self._enrich_with_unlock_values(player_data) 40 | 41 | def get_blizzard_url(self, **kwargs) -> str: 42 | # Replace dash by encoded number sign (#) for search 43 | player_name = kwargs.get("player_id").split("-", 1)[0] 44 | return f"{super().get_blizzard_url(**kwargs)}/{player_name}/" 45 | 46 | async def _enrich_with_unlock_values(self, player_data: dict) -> dict: 47 | """Enrich player data with unlock values""" 48 | 49 | # First cache unlock data if not already done 50 | unlock_ids = { 51 | player_data[key] 52 | for key in settings.unlock_keys 53 | if player_data[key] is not None 54 | } 55 | 56 | await self.unlocks_manager.cache_values(unlock_ids) 57 | 58 | # Then return values with existing unlock keys replaced by their respective values 59 | return { 60 | key: ( 61 | self.unlocks_manager.get(value) 62 | if key in settings.unlock_keys 63 | else value 64 | ) 65 | for key, value in player_data.items() 66 | } 67 | -------------------------------------------------------------------------------- /app/roles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/roles/__init__.py -------------------------------------------------------------------------------- /app/roles/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/roles/controllers/__init__.py -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/roles/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/app/roles/parsers/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/unlocks_manager.py: -------------------------------------------------------------------------------- 1 | from itertools import batched 2 | from typing import ClassVar 3 | 4 | from fastapi import HTTPException 5 | 6 | from app.cache_manager import CacheManager 7 | from app.config import settings 8 | from app.enums import Locale 9 | from app.helpers import send_discord_webhook_message 10 | from app.overfast_client import OverFastClient 11 | from app.overfast_logger import logger 12 | from app.players.enums import UnlockDataType 13 | 14 | from .metaclasses import Singleton 15 | 16 | 17 | class UnlocksManager(metaclass=Singleton): 18 | """Unlock manager main class, containing methods to retrieve 19 | and store data for unlocks : avatars, namecards, titles. 20 | """ 21 | 22 | # Mapping between Blizzard type and API type 23 | data_type_mapping: ClassVar[dict[str, UnlockDataType]] = { 24 | "Player Icons": UnlockDataType.PORTRAIT, 25 | "Name Cards": UnlockDataType.NAMECARD, 26 | "Player Titles": UnlockDataType.TITLE, 27 | } 28 | 29 | # Mapping between API type and value key in Blizzard data 30 | data_value_mapping: ClassVar[dict[UnlockDataType, str]] = { 31 | UnlockDataType.PORTRAIT: "icon", 32 | UnlockDataType.NAMECARD: "icon", 33 | UnlockDataType.TITLE: "name", 34 | } 35 | 36 | def __init__(self): 37 | self.cache_manager = CacheManager() 38 | self.overfast_client = OverFastClient() 39 | 40 | def get(self, unlock_id: str) -> str | None: 41 | """Retrieve unlock value from cache""" 42 | return self.cache_manager.get_unlock_data_cache(unlock_id) 43 | 44 | async def cache_values(self, unlock_ids: set[str]) -> None: 45 | """Cache values for unlock ids""" 46 | 47 | # We'll ignore already cached unlock values 48 | missing_unlock_ids = { 49 | unlock_id for unlock_id in unlock_ids if not self.get(unlock_id) 50 | } 51 | 52 | # If everything is already cached, no need to do anything 53 | if not missing_unlock_ids: 54 | return 55 | 56 | logger.info("Retrieving {} missing unlock ids...", len(missing_unlock_ids)) 57 | 58 | # Make API calls to Blizzard to retrieve and cache values 59 | raw_unlock_data = await self._get_unlock_data_from_blizzard(missing_unlock_ids) 60 | 61 | # Loop over the results and store data value depending on unlock type 62 | unlock_data: dict[str, str] = { 63 | data["id"]: data[self.data_value_mapping[unlock_type]] 64 | for data in raw_unlock_data 65 | if (unlock_type := self.data_type_mapping.get(data["type"]["name"])) 66 | } 67 | 68 | self.cache_manager.update_unlock_data_cache(unlock_data) 69 | 70 | async def _get_unlock_data_from_blizzard(self, unlock_ids: set[str]) -> list[dict]: 71 | """Retrieve unlock data from Blizzard. Chunk API calls as there is a limit.""" 72 | unlock_data: list[dict] = [] 73 | 74 | for batch in batched(unlock_ids, settings.unlock_data_batch_size, strict=False): 75 | try: 76 | response = await self.overfast_client.get( 77 | url=f"{settings.blizzard_host}/{Locale.ENGLISH_US}{settings.unlock_data_path}", 78 | params={"unlockIds": ",".join(batch)}, 79 | ) 80 | except HTTPException as err: 81 | error_message = ( 82 | f"Error while retrieving unlock data from Blizzard : {err}" 83 | ) 84 | logger.error(error_message) 85 | send_discord_webhook_message(error_message) 86 | 87 | # Return already loaded unlock_data 88 | return unlock_data 89 | 90 | unlock_data.extend(response.json()) 91 | 92 | return unlock_data 93 | -------------------------------------------------------------------------------- /build/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build arguments 2 | ARG OPENRESTY_VERSION=1.27.1.1-1 3 | 4 | # Use the OpenResty Alpine base image 5 | FROM openresty/openresty:${OPENRESTY_VERSION}-alpine-fat 6 | 7 | # Environment variables 8 | ARG OPENRESTY_VERSION 9 | ENV OPENRESTY_VERSION=${OPENRESTY_VERSION} 10 | 11 | # For envsubst command in entrypoint 12 | RUN apk add gettext git zlib-dev 13 | 14 | # Install zlib 15 | RUN luarocks install lua-zlib 16 | 17 | # Copy Nginx configuration file 18 | COPY overfast-api.conf.template /etc/nginx/conf.d/default.conf.template 19 | 20 | # Copy Lua scripts 21 | COPY redis_handler.lua.template /usr/local/openresty/lualib/redis_handler.lua.template 22 | 23 | # Add an entrypoint script (optional) 24 | COPY entrypoint.sh /entrypoint.sh 25 | RUN chmod +x /entrypoint.sh -------------------------------------------------------------------------------- /build/nginx/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Replace placeholders and generate config and lua script from templates 4 | envsubst '${RATE_LIMIT_PER_SECOND_PER_IP} ${RATE_LIMIT_PER_IP_BURST} ${MAX_CONNECTIONS_PER_IP} ${RETRY_AFTER_HEADER}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf 5 | envsubst '${REDIS_HOST} ${REDIS_PORT} ${CACHE_TTL_HEADER}' < /usr/local/openresty/lualib/redis_handler.lua.template > /usr/local/openresty/lualib/redis_handler.lua 6 | 7 | # Check OpenResty config before starting 8 | openresty -t 9 | 10 | # Start OpenResty 11 | openresty -g "daemon off;" -------------------------------------------------------------------------------- /build/nginx/overfast-api.conf.template: -------------------------------------------------------------------------------- 1 | # Create connection zone to limit number of connections per IP/domain 2 | limit_conn_zone $http_x_forwarded_for zone=ofconn:10m; 3 | limit_conn_status 429; 4 | 5 | # Create request zone to introduce a rate limit per IP 6 | limit_req_zone $http_x_forwarded_for zone=ofreq:10m rate=${RATE_LIMIT_PER_SECOND_PER_IP}r/s; 7 | limit_req_status 429; 8 | 9 | upstream appbackend { 10 | server app:8080; 11 | } 12 | 13 | # Use Docker's internal DNS resolver for Lua script 14 | resolver 127.0.0.11 valid=30s; 15 | 16 | server { 17 | listen 80; 18 | 19 | # Use nginx to serve static content 20 | location /static { 21 | alias /static; 22 | try_files $uri =404; 23 | 24 | sendfile on; 25 | tcp_nopush on; 26 | 27 | expires 1d; 28 | add_header Cache-Control public; 29 | add_header Access-Control-Allow-Origin "*" always; 30 | } 31 | 32 | # Favicon 33 | location /favicon.png { 34 | alias /static/favicon.png; 35 | } 36 | location /favicon.ico { 37 | alias /static/favicon.ico; 38 | } 39 | 40 | # Redirect trailing slashes to routes without slashes 41 | location ~ (?.+)/$ { 42 | return 301 $scheme://$host$no_slash; 43 | } 44 | 45 | # Main route 46 | location / { 47 | # Rate limiting instructions 48 | limit_conn ofconn ${MAX_CONNECTIONS_PER_IP}; 49 | limit_req zone=ofreq burst=${RATE_LIMIT_PER_IP_BURST} nodelay; 50 | 51 | # Handle HTTP 429 when triggered by nginx rate limit 52 | error_page 429 = @limit_reached; 53 | 54 | # Always authorize any origin as it's a public API 55 | add_header Access-Control-Allow-Origin "*" always; 56 | 57 | # Handle OPTIONS method here 58 | if ($request_method = OPTIONS) { 59 | add_header Access-Control-Allow-Origin "*" always; 60 | add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS"; 61 | add_header Access-Control-Allow-Headers "*"; 62 | return 204; 63 | } 64 | 65 | # Use Lua script for Redis logic 66 | default_type application/json; 67 | content_by_lua_block { 68 | local redis_handler = require "redis_handler" 69 | redis_handler() 70 | } 71 | 72 | # Fallback to app if data not in cache 73 | error_page 404 502 504 = @fallback; 74 | } 75 | 76 | # FastAPI app fallback 77 | location @fallback { 78 | # As we're in fallback, we need to specify the header again 79 | add_header Access-Control-Allow-Origin "*" always; 80 | 81 | # Main proxy method 82 | proxy_pass http://appbackend; 83 | 84 | # Ensure headers are forwarded 85 | proxy_set_header Host $host; 86 | proxy_set_header X-Real-IP $remote_addr; 87 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 88 | proxy_set_header X-Forwarded-Proto $scheme; 89 | proxy_pass_request_headers on; 90 | 91 | # Ensure HEAD requests are passed as GET 92 | proxy_method GET; 93 | } 94 | 95 | # nginx rate limit reached 96 | location @limit_reached { 97 | add_header Access-Control-Allow-Origin "*" always; 98 | add_header ${RETRY_AFTER_HEADER} 1 always; 99 | 100 | default_type application/json; 101 | return 429 '{"error": "API rate limit reached, please wait for 1 second before retrying"}'; 102 | } 103 | } -------------------------------------------------------------------------------- /build/nginx/redis_handler.lua.template: -------------------------------------------------------------------------------- 1 | local zlib = require "zlib" 2 | local redis = require "resty.redis" 3 | 4 | local function handle_redis_request() 5 | -- Initialize Redis client 6 | local red = redis:new() 7 | red:set_timeout(1000) -- 1 second timeout 8 | 9 | -- Connect to Redis using hostname and port 10 | local ok, err = red:connect("${REDIS_HOST}", ${REDIS_PORT}) 11 | if not ok then 12 | ngx.log(ngx.ERR, "Failed to connect to Redis upstream: ", err) 13 | return ngx.exit(502) 14 | end 15 | 16 | -- Redis operations (e.g., GET, TTL) 17 | local key = "api-cache:" .. ngx.var.request_uri 18 | local compressed_value, err = red:get(key) 19 | if not compressed_value or compressed_value == ngx.null then 20 | ngx.log(ngx.INFO, "Cache miss for key: ", key) 21 | ngx.exec("@fallback") 22 | return 23 | end 24 | 25 | local ttl, err = red:ttl(key) 26 | if err then 27 | ngx.log(ngx.ERR, "Failed to get TTL for key: ", key, " Error: ", err) 28 | return ngx.exit(502) 29 | end 30 | 31 | -- Decompress JSON data 32 | local status, value = pcall(function() 33 | return zlib.inflate()(compressed_value) 34 | end) 35 | if not value or value == ngx.null then 36 | ngx.log(ngx.ERR, "Cache error for key: ", key) 37 | ngx.exec("@fallback") 38 | return 39 | end 40 | 41 | -- Return response 42 | ngx.header["${CACHE_TTL_HEADER}"] = ttl 43 | ngx.say(value) 44 | end 45 | 46 | return handle_redis_request 47 | -------------------------------------------------------------------------------- /build/overfast-crontab: -------------------------------------------------------------------------------- 1 | 0 2 * * * cd /code && .venv/bin/python -m app.heroes.commands.check_new_hero -------------------------------------------------------------------------------- /build/redis/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG REDIS_VERSION=7 2 | 3 | FROM redis:${REDIS_VERSION}-alpine 4 | 5 | WORKDIR /redis 6 | 7 | COPY init.sh ./ 8 | 9 | RUN chmod +x init.sh -------------------------------------------------------------------------------- /build/redis/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. 4 | # To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot 5 | # or run the command 'sysctl vm.overcommit_memory=1' for this to take effect. 6 | # The overcommit_memory has 3 options. 7 | # 0, the system kernel check if there is enough memory to be allocated to the process or not, 8 | # if not enough, it will return errors to the process. 9 | # 1, the system kernel is allowed to allocate the whole memory to the process 10 | # no matter what the status of memory is. 11 | # 2, the system kernel is allowed to allocate a memory whose size could be bigger than 12 | # the sum of the size of physical memory and the size of exchange workspace to the process. 13 | sysctl vm.overcommit_memory=1 14 | 15 | # Start redis server 16 | redis-server \ 17 | --save 60 1 \ 18 | --loglevel warning \ 19 | --maxmemory ${REDIS_MEMORY_LIMIT} \ 20 | --maxmemory-policy allkeys-lru -------------------------------------------------------------------------------- /build/reverse-proxy/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | 6 | location / { 7 | proxy_pass http://nginxbackend; 8 | 9 | # Ensure headers are forwarded 10 | proxy_set_header Host $host; 11 | proxy_set_header X-Real-IP $remote_addr; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | proxy_pass_request_headers on; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /build/reverse-proxy/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log notice; 5 | pid /var/run/nginx.pid; 6 | 7 | events { 8 | worker_connections 1024; 9 | } 10 | 11 | http { 12 | include /etc/nginx/mime.types; 13 | default_type application/octet-stream; 14 | 15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 16 | '$status $body_bytes_sent "$http_referer" ' 17 | '"$http_user_agent" "$http_x_forwarded_for"'; 18 | 19 | access_log /var/log/nginx/access.log main; 20 | 21 | sendfile on; 22 | keepalive_timeout 65; 23 | 24 | upstream nginxbackend { 25 | server nginx:80; 26 | } 27 | 28 | include /etc/nginx/conf.d/*.conf; 29 | } -------------------------------------------------------------------------------- /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 | redis: 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 | redis: 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 | redis: 36 | build: 37 | context: ./build/redis 38 | command: sh -c "./init.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 | volumes: 43 | - ${APP_VOLUME_PATH}/redis-data:/data 44 | restart: always 45 | deploy: 46 | resources: 47 | limits: 48 | memory: ${REDIS_MEMORY_LIMIT:-1gb} 49 | healthcheck: 50 | test: ["CMD", "redis-cli", "ping"] 51 | interval: 3s 52 | timeout: 2s 53 | 54 | nginx: 55 | build: 56 | context: ./build/nginx 57 | ports: 58 | - "${APP_PORT}:80" 59 | entrypoint: ["/entrypoint.sh"] 60 | env_file: ${APP_VOLUME_PATH:-.}/.env 61 | volumes: 62 | - static_volume:/static 63 | depends_on: 64 | app: 65 | condition: service_healthy 66 | redis: 67 | condition: service_healthy 68 | healthcheck: 69 | test: ["CMD-SHELL", "wget --spider --quiet http://localhost || exit 1"] 70 | interval: 5s 71 | timeout: 2s 72 | 73 | reverse-proxy: 74 | profiles: 75 | - testing 76 | image: nginx:alpine 77 | ports: 78 | - "8080:80" 79 | volumes: 80 | - ./build/reverse-proxy/default.conf:/etc/nginx/conf.d/default.conf 81 | - ./build/reverse-proxy/nginx.conf:/etc/nginx/nginx.conf 82 | depends_on: 83 | nginx: 84 | condition: service_started 85 | healthcheck: 86 | test: ["CMD-SHELL", "wget --spider --quiet http://localhost || exit 1"] 87 | interval: 5s 88 | timeout: 2s 89 | 90 | volumes: 91 | static_volume: -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "overfast-api" 3 | version = "3.13.0" 4 | description = "Overwatch API giving data about heroes, maps, and players statistics." 5 | license = {file = "LICENSE"} 6 | authors = [ 7 | {name = "Valentin PORCHET", email = "valentin.porchet@proton.me"} 8 | ] 9 | readme = "README.md" 10 | requires-python = ">=3.13" 11 | dependencies = [ 12 | "fastapi[standard]==0.115.*", 13 | "httpx[http2]==0.28.*", 14 | "loguru==0.7.*", 15 | "redis==6.1.*", 16 | "pydantic==2.11.*", 17 | "pydantic-settings==2.9.*", 18 | "selectolax==0.3.*", 19 | ] 20 | 21 | [project.urls] 22 | Homepage = "https://overfast-api.tekrop.fr" 23 | Documentation = "https://overfast-api.tekrop.fr" 24 | Repository = "https://github.com/TeKrop/overfast-api" 25 | Issues = "https://github.com/TeKrop/overfast-api/issues" 26 | 27 | [tool.uv] 28 | compile-bytecode = true 29 | dev-dependencies = [ 30 | "fakeredis==2.28.*", 31 | "ipdb==0.13.*", 32 | "pytest==8.3.*", 33 | "pytest-asyncio==0.26.*", 34 | "pytest-cov==6.1.*", 35 | "pytest-randomly==3.16.*", 36 | "pytest-xdist==3.6.*", 37 | "ruff==0.11.*", 38 | "pre-commit==4.2.*", 39 | "pyinstrument>=5.0.0", 40 | "memray>=1.14.0", 41 | "objgraph>=3.6.2", 42 | ] 43 | 44 | [tool.ruff] 45 | # Check app code and tests 46 | src = ["app", "tests"] 47 | 48 | # Assume Python 3.13 49 | target-version = "py313" 50 | 51 | [tool.ruff.lint] 52 | select = [ 53 | "E", # pycodestyle errors 54 | "W", # pycodestyle warnings 55 | "F", # pyflakes 56 | "C90", # mccabe complexity checker 57 | "I001", # isort 58 | "N", # pep8-naming 59 | "UP", # pyupgrade 60 | "ASYNC", # flake8-async 61 | "S", # flake8-bandit 62 | "BLE", # flake8-blind-except 63 | "B", # flake8-bugbear 64 | "A", # flake8-builtins 65 | "COM", # flake8-commas 66 | "C4", # flake8-comprehensions 67 | "DTZ", # flake8-datetimez 68 | "T10", # flake8-debugger 69 | "EM", # flake8-errmsg 70 | "EXE", # flake8-executable 71 | "FIX", # flake8-fixme 72 | "FA", # flake8-future-annotations 73 | "INT", # flake8-gettext 74 | "ISC", # flake8-implicit-str-concat 75 | "ICN", # flake8-import-conventions 76 | "LOG", # flake8-logging 77 | "G", # flake8-logging-format 78 | "INP", # flake8-no-pep420 79 | "PIE", # flake8-pie 80 | "T20", # flake8-print 81 | "PYI", # flake8-pyi 82 | "PT", # flake8-pytest-style 83 | "Q", # flake8-quotes 84 | "RSE", # flake8-raise 85 | "RET", # flake8-return 86 | "SLF", # flake8-self 87 | "SLOT", # flake8-slots 88 | "SIM", # flake8-simplify 89 | "TID", # flake8-tidy-imports 90 | "TCH", # flake8-type-checking 91 | "ARG", # flake8-unused-arguments 92 | "PTH", # flake8-use-pathlib 93 | "ERA", # eradicate commented-out code 94 | "PGH", # pygrep-hooks 95 | "PL", # pylint 96 | "TRY", # tryceratops 97 | "FLY", # flynt 98 | "FAST", # FastAPI rules 99 | "PERF", # perflint 100 | "FURB", # refurb 101 | "RUF", # ruff-specific rules 102 | ] 103 | ignore = [ 104 | # General rules to ignore 105 | "B008", # do not perform function calls in argument defaults 106 | "S101", # using "assert" is not a security issue 107 | "S113", # using default timeout of httpx without specifying it 108 | "S311", # there is no cryptographic usage of random here 109 | "RET505", # allow using else after return statement 110 | "PLE1205", # error checking doesn't support {} format 111 | "PLR0913", # allow 6/7 arguments for some functions 112 | "TID252", # allow relative imports from parents 113 | "FAST003", # false positive on router parameters 114 | 115 | # Rules already handled by ruff formatter 116 | "E501", # line too long 117 | "COM812", # missing trailing comma 118 | "COM819", # prohibited trailing comma 119 | "ISC001", # single line implicit string concatenation 120 | "Q000", # bad quotes in inline string 121 | "Q001", # bad quotes in multiline string 122 | "Q002", # bad quotes in docstring 123 | "Q003", # avoidable escape quote 124 | "W191" # tab indentation detected instead of spaces 125 | ] 126 | # Allow some confusable UTF8 chars (used in regexp) 127 | allowed-confusables = ["(", ")", ":"] 128 | 129 | [tool.ruff.lint.per-file-ignores] 130 | "tests/**" = ["SLF001"] # Ignore private member access on tests 131 | 132 | [tool.ruff.lint.isort] 133 | # Consider app as first-party for imports in tests 134 | known-first-party = ["app"] 135 | 136 | [tool.pytest.ini_options] 137 | # Put this default value to prevent warnings 138 | asyncio_default_fixture_loop_scope = "function" 139 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/favicon.png -------------------------------------------------------------------------------- /static/gamemodes/assault-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/gamemodes/assault.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/assault.avif -------------------------------------------------------------------------------- /static/gamemodes/capture-the-flag-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/gamemodes/capture-the-flag.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/capture-the-flag.avif -------------------------------------------------------------------------------- /static/gamemodes/clash-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/gamemodes/clash.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/clash.avif -------------------------------------------------------------------------------- /static/gamemodes/control-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/gamemodes/control.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/control.avif -------------------------------------------------------------------------------- /static/gamemodes/deathmatch.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/deathmatch.avif -------------------------------------------------------------------------------- /static/gamemodes/elimination-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /static/gamemodes/elimination.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/elimination.avif -------------------------------------------------------------------------------- /static/gamemodes/escort-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /static/gamemodes/escort.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/escort.avif -------------------------------------------------------------------------------- /static/gamemodes/flashpoint-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /static/gamemodes/flashpoint.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/flashpoint.avif -------------------------------------------------------------------------------- /static/gamemodes/hybrid-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /static/gamemodes/hybrid.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/hybrid.avif -------------------------------------------------------------------------------- /static/gamemodes/practice-range.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/practice-range.avif -------------------------------------------------------------------------------- /static/gamemodes/push-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /static/gamemodes/push.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/push.avif -------------------------------------------------------------------------------- /static/gamemodes/team-deathmatch.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/gamemodes/team-deathmatch.avif -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/logo.png -------------------------------------------------------------------------------- /static/maps/antarctic_peninsula.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/antarctic_peninsula.jpg -------------------------------------------------------------------------------- /static/maps/anubis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/anubis.jpg -------------------------------------------------------------------------------- /static/maps/arena_victoriae.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/arena_victoriae.jpg -------------------------------------------------------------------------------- /static/maps/ayutthaya.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/ayutthaya.jpg -------------------------------------------------------------------------------- /static/maps/black_forest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/black_forest.jpg -------------------------------------------------------------------------------- /static/maps/blizzard_world.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/blizzard_world.jpg -------------------------------------------------------------------------------- /static/maps/busan.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/busan.jpg -------------------------------------------------------------------------------- /static/maps/castillo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/castillo.jpg -------------------------------------------------------------------------------- /static/maps/chateau_guillard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/chateau_guillard.jpg -------------------------------------------------------------------------------- /static/maps/circuit_royal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/circuit_royal.jpg -------------------------------------------------------------------------------- /static/maps/colosseo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/colosseo.jpg -------------------------------------------------------------------------------- /static/maps/dorado.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/dorado.jpg -------------------------------------------------------------------------------- /static/maps/ecopoint_antarctica.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/ecopoint_antarctica.jpg -------------------------------------------------------------------------------- /static/maps/eichenwalde.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/eichenwalde.jpg -------------------------------------------------------------------------------- /static/maps/esperanca.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/esperanca.jpg -------------------------------------------------------------------------------- /static/maps/gibraltar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/gibraltar.jpg -------------------------------------------------------------------------------- /static/maps/gogadoro.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/gogadoro.jpg -------------------------------------------------------------------------------- /static/maps/hanamura.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/hanamura.jpg -------------------------------------------------------------------------------- /static/maps/hanaoka.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/hanaoka.jpg -------------------------------------------------------------------------------- /static/maps/havana.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/havana.jpg -------------------------------------------------------------------------------- /static/maps/hollywood.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/hollywood.jpg -------------------------------------------------------------------------------- /static/maps/horizon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/horizon.jpg -------------------------------------------------------------------------------- /static/maps/ilios.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/ilios.jpg -------------------------------------------------------------------------------- /static/maps/junkertown.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/junkertown.jpg -------------------------------------------------------------------------------- /static/maps/kanezaka.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/kanezaka.jpg -------------------------------------------------------------------------------- /static/maps/kings_row.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/kings_row.jpg -------------------------------------------------------------------------------- /static/maps/lijiang.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/lijiang.jpg -------------------------------------------------------------------------------- /static/maps/malevento.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/malevento.jpg -------------------------------------------------------------------------------- /static/maps/midtown.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/midtown.jpg -------------------------------------------------------------------------------- /static/maps/necropolis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/necropolis.jpg -------------------------------------------------------------------------------- /static/maps/nepal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/nepal.jpg -------------------------------------------------------------------------------- /static/maps/new_junk_city.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/new_junk_city.jpg -------------------------------------------------------------------------------- /static/maps/new_queen_street.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/new_queen_street.jpg -------------------------------------------------------------------------------- /static/maps/numbani.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/numbani.jpg -------------------------------------------------------------------------------- /static/maps/oasis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/oasis.jpg -------------------------------------------------------------------------------- /static/maps/paraiso.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/paraiso.jpg -------------------------------------------------------------------------------- /static/maps/paris.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/paris.jpg -------------------------------------------------------------------------------- /static/maps/petra.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/petra.jpg -------------------------------------------------------------------------------- /static/maps/place_lacroix.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/place_lacroix.jpg -------------------------------------------------------------------------------- /static/maps/practice_range.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/practice_range.jpg -------------------------------------------------------------------------------- /static/maps/redwood_dam.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/redwood_dam.jpg -------------------------------------------------------------------------------- /static/maps/rialto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/rialto.jpg -------------------------------------------------------------------------------- /static/maps/route_66.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/route_66.jpg -------------------------------------------------------------------------------- /static/maps/runasapi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/runasapi.jpg -------------------------------------------------------------------------------- /static/maps/samoa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/samoa.jpg -------------------------------------------------------------------------------- /static/maps/shambali.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/shambali.jpg -------------------------------------------------------------------------------- /static/maps/suravasa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/suravasa.jpg -------------------------------------------------------------------------------- /static/maps/throne_of_anubis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/throne_of_anubis.jpg -------------------------------------------------------------------------------- /static/maps/volskaya.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/static/maps/volskaya.jpg -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/__init__.py -------------------------------------------------------------------------------- /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 redis_server(): 17 | return fakeredis.FakeStrictRedis() 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def _patch_before_every_test(redis_server: fakeredis.FakeStrictRedis): 22 | # Flush Redis before and after every tests 23 | redis_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.redis_server", 30 | redis_server, 31 | ), 32 | ): 33 | yield 34 | 35 | redis_server.flushdb() 36 | -------------------------------------------------------------------------------- /tests/fixtures/json/blizzard_unlock_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "event": { 4 | "id": "0x0D8000000000410B", 5 | "name": "Overwatch" 6 | }, 7 | "hero": null, 8 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/c3090e3a1dccc58f143ff53801bc0cecb139f0eb1278f157d0b5e29db9104bed.png", 9 | "id": "0x02500000000002F7", 10 | "name": "Overwatch Dark", 11 | "rarity": { 12 | "id": "0x0D80000000003DA0", 13 | "name": "Rare" 14 | }, 15 | "resourceKey": { 16 | "id": "0x0F10000000000065", 17 | "name": "1.0 Beta" 18 | }, 19 | "type": { 20 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/969d368195ef34bdb28c70b621bfd593f310294b6f563d69183087547c05de28.png", 21 | "id": "0x0D80000000006056", 22 | "name": "Player Icons" 23 | } 24 | }, 25 | { 26 | "event": { 27 | "id": "0x0D8000000000B647", 28 | "name": "Season 2" 29 | }, 30 | "hero": null, 31 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/6e991c9791811fe6174f3ea0ba127120b6e74b03e1d11ababb0dcd8418081c23.png", 32 | "id": "0x02500000000056C8", 33 | "name": "Plutomari", 34 | "rarity": { 35 | "id": "0x0D80000000003DA0", 36 | "name": "Rare" 37 | }, 38 | "resourceKey": { 39 | "id": "0x0F1000000000019D", 40 | "name": "2.2.0.2 - BP Season 2" 41 | }, 42 | "type": { 43 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/969d368195ef34bdb28c70b621bfd593f310294b6f563d69183087547c05de28.png", 44 | "id": "0x0D80000000006056", 45 | "name": "Player Icons" 46 | } 47 | }, 48 | { 49 | "event": { 50 | "id": "0x0D8000000000B647", 51 | "name": "Season 2" 52 | }, 53 | "hero": null, 54 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/52ee742d4e2fc734e3cd7fdb74b0eac64bcdf26d58372a503c712839595802c5.png", 55 | "id": "0x02500000000056EA", 56 | "name": "Cerberus", 57 | "rarity": { 58 | "id": "0x0D80000000003DA0", 59 | "name": "Rare" 60 | }, 61 | "resourceKey": { 62 | "id": "0x0F1000000000019D", 63 | "name": "2.2.0.2 - BP Season 2" 64 | }, 65 | "type": { 66 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/969d368195ef34bdb28c70b621bfd593f310294b6f563d69183087547c05de28.png", 67 | "id": "0x0D8000000000AE9D", 68 | "name": "Name Cards" 69 | } 70 | }, 71 | { 72 | "event": { 73 | "id": "0x0D8000000000410B", 74 | "name": "Overwatch" 75 | }, 76 | "hero": null, 77 | "icon": "", 78 | "id": "0x02500000000054DA", 79 | "name": "No Title", 80 | "rarity": { 81 | "id": "0x0D800000000060F9", 82 | "name": "Common" 83 | }, 84 | "resourceKey": { 85 | "id": "0x0F10000000000194", 86 | "name": "2.01.0.0 - F2P Launch Branch - Season 1" 87 | }, 88 | "type": { 89 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/969d368195ef34bdb28c70b621bfd593f310294b6f563d69183087547c05de28.png", 90 | "id": "0x0D8000000000AE9E", 91 | "name": "Player Titles" 92 | } 93 | }, 94 | { 95 | "event": { 96 | "id": "0x0D8000000000AD37", 97 | "name": "Season 1" 98 | }, 99 | "hero": null, 100 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/08530036fe6b13775f26a14096f9ad291f65e4e2a3ab0d2fb1810e31f476cd3e.png", 101 | "id": "0x025000000000555E", 102 | "name": "Bytefixer", 103 | "rarity": { 104 | "id": "0x0D800000000060F9", 105 | "name": "Common" 106 | }, 107 | "resourceKey": { 108 | "id": "0x0F10000000000194", 109 | "name": "2.01.0.0 - F2P Launch Branch - Season 1" 110 | }, 111 | "type": { 112 | "icon": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/969d368195ef34bdb28c70b621bfd593f310294b6f563d69183087547c05de28.png", 113 | "id": "0x0D8000000000AE9E", 114 | "name": "Player Titles" 115 | } 116 | } 117 | ] -------------------------------------------------------------------------------- /tests/gamemodes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/gamemodes/__init__.py -------------------------------------------------------------------------------- /tests/gamemodes/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/gamemodes/parsers/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/gamemodes/parsers/test_gamemodes_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.exceptions import OverfastError 4 | from app.gamemodes.parsers.gamemodes_parser import GamemodesParser 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_gamemodes_page_parsing(gamemodes_parser: GamemodesParser): 9 | try: 10 | await gamemodes_parser.parse() 11 | except OverfastError: 12 | pytest.fail("Game modes list parsing failed") 13 | 14 | # Just check the format of the first gamemode in the list 15 | assert gamemodes_parser.data[0] == { 16 | "key": "assault", 17 | "name": "Assault", 18 | "icon": "https://overfast-api.tekrop.fr/static/gamemodes/assault-icon.svg", 19 | "description": "Teams fight to capture or defend two successive points against the enemy team. It's an inactive Overwatch 1 gamemode, also called 2CP.", 20 | "screenshot": "https://overfast-api.tekrop.fr/static/gamemodes/assault.avif", 21 | } 22 | -------------------------------------------------------------------------------- /tests/gamemodes/test_gamemodes_route.py: -------------------------------------------------------------------------------- 1 | from fastapi import status 2 | from fastapi.testclient import TestClient 3 | 4 | 5 | def test_get_gamemodes(client: TestClient): 6 | response = client.get("/gamemodes") 7 | assert response.status_code == status.HTTP_200_OK 8 | assert len(response.json()) > 0 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/heroes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/heroes/__init__.py -------------------------------------------------------------------------------- /tests/heroes/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/heroes/commands/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/heroes/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from _pytest.fixtures import SubRequest 3 | 4 | from tests.helpers import read_html_file 5 | 6 | 7 | @pytest.fixture(scope="package") 8 | def heroes_html_data(): 9 | return read_html_file("heroes.html") 10 | 11 | 12 | @pytest.fixture(scope="package") 13 | def hero_html_data(request: SubRequest): 14 | return read_html_file(f"heroes/{request.param}.html") 15 | -------------------------------------------------------------------------------- /tests/heroes/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/heroes/controllers/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/heroes/controllers/test_heroes_controllers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from app.heroes.controllers.get_hero_controller import GetHeroController 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("input_dict", "key", "new_key", "new_value"), 10 | [ 11 | # Empty dict 12 | ({}, "key", "new_key", "new_value"), 13 | # Key doesn't exist 14 | ({"key_one": 1, "key_two": 2}, "key", "new_key", "new_value"), 15 | ], 16 | ) 17 | def test_dict_insert_value_before_key_with_key_error( 18 | get_hero_controller: GetHeroController, 19 | input_dict: dict, 20 | key: str, 21 | new_key: str, 22 | new_value: Any, 23 | ): 24 | with pytest.raises(KeyError): 25 | get_hero_controller._GetHeroController__dict_insert_value_before_key( 26 | input_dict, key, new_key, new_value 27 | ) 28 | 29 | 30 | @pytest.mark.parametrize( 31 | ("input_dict", "key", "new_key", "new_value", "result_dict"), 32 | [ 33 | # Before first key 34 | ( 35 | {"key_one": 1, "key_two": 2, "key_three": 3}, 36 | "key_one", 37 | "key_four", 38 | 4, 39 | {"key_four": 4, "key_one": 1, "key_two": 2, "key_three": 3}, 40 | ), 41 | # Before middle key 42 | ( 43 | {"key_one": 1, "key_two": 2, "key_three": 3}, 44 | "key_two", 45 | "key_four", 46 | 4, 47 | {"key_one": 1, "key_four": 4, "key_two": 2, "key_three": 3}, 48 | ), 49 | # Before last key 50 | ( 51 | {"key_one": 1, "key_two": 2, "key_three": 3}, 52 | "key_three", 53 | "key_four", 54 | 4, 55 | {"key_one": 1, "key_two": 2, "key_four": 4, "key_three": 3}, 56 | ), 57 | ], 58 | ) 59 | def test_dict_insert_value_before_key_valid( 60 | get_hero_controller: GetHeroController, 61 | input_dict: dict, 62 | key: str, 63 | new_key: str, 64 | new_value: Any, 65 | result_dict: dict, 66 | ): 67 | assert ( 68 | get_hero_controller._GetHeroController__dict_insert_value_before_key( 69 | input_dict, key, new_key, new_value 70 | ) 71 | == result_dict 72 | ) 73 | -------------------------------------------------------------------------------- /tests/heroes/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/heroes/parsers/__init__.py -------------------------------------------------------------------------------- /tests/heroes/parsers/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.heroes.parsers.hero_parser import HeroParser 4 | from app.heroes.parsers.heroes_parser import HeroesParser 5 | 6 | 7 | @pytest.fixture(scope="package") 8 | def hero_parser() -> HeroParser: 9 | return HeroParser() 10 | 11 | 12 | @pytest.fixture(scope="package") 13 | def heroes_parser() -> HeroesParser: 14 | return HeroesParser() 15 | -------------------------------------------------------------------------------- /tests/heroes/parsers/test_hero_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from fastapi import status 5 | 6 | from app.config import settings 7 | from app.enums import Locale 8 | from app.exceptions import OverfastError, ParserBlizzardError 9 | from app.heroes.enums import HeroKey 10 | from app.heroes.parsers.hero_parser import HeroParser 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("hero_key", "hero_html_data"), 15 | [(h.value, h.value) for h in HeroKey], 16 | indirect=["hero_html_data"], 17 | ) 18 | @pytest.mark.asyncio 19 | async def test_hero_page_parsing( 20 | hero_parser: HeroParser, hero_key: str, hero_html_data: str 21 | ): 22 | if not hero_html_data: 23 | pytest.skip("Hero HTML file not saved yet, skipping") 24 | 25 | with patch( 26 | "httpx.AsyncClient.get", 27 | return_value=Mock(status_code=status.HTTP_200_OK, text=hero_html_data), 28 | ): 29 | try: 30 | await hero_parser.parse() 31 | except OverfastError: 32 | pytest.fail(f"Hero page parsing failed for '{hero_key}' hero") 33 | 34 | 35 | @pytest.mark.parametrize("hero_html_data", ["unknown-hero"], indirect=True) 36 | @pytest.mark.asyncio 37 | async def test_not_released_hero_parser_blizzard_error( 38 | hero_parser: HeroParser, hero_html_data: str 39 | ): 40 | with ( 41 | pytest.raises(ParserBlizzardError), 42 | patch( 43 | "httpx.AsyncClient.get", 44 | return_value=Mock( 45 | status_code=status.HTTP_404_NOT_FOUND, text=hero_html_data 46 | ), 47 | ), 48 | ): 49 | await hero_parser.parse() 50 | 51 | 52 | @pytest.mark.parametrize( 53 | ("url", "full_url"), 54 | [ 55 | ( 56 | "https://www.youtube.com/watch?v=yzFWIw7wV8Q", 57 | "https://www.youtube.com/watch?v=yzFWIw7wV8Q", 58 | ), 59 | ("/media/stories/bastet", f"{settings.blizzard_host}/media/stories/bastet"), 60 | ], 61 | ) 62 | def test_get_full_url(hero_parser: HeroParser, url: str, full_url: str): 63 | assert hero_parser._HeroParser__get_full_url(url) == full_url 64 | 65 | 66 | @pytest.mark.parametrize( 67 | ("input_str", "locale", "result"), 68 | [ 69 | # Classic cases 70 | ("Aug 19 (Age: 37)", Locale.ENGLISH_US, ("Aug 19", 37)), 71 | ("May 9 (Age: 1)", Locale.ENGLISH_US, ("May 9", 1)), 72 | # Specific unknown case (bastion) 73 | ("Unknown (Age: 32)", Locale.ENGLISH_US, (None, 32)), 74 | # Specific venture case (not the same spacing) 75 | ("Aug 6 (Age : 26)", Locale.ENGLISH_US, ("Aug 6", 26)), 76 | ("Aug 6 (Age : 26)", Locale.ENGLISH_EU, ("Aug 6", 26)), 77 | # Other languages than english 78 | ("6. Aug. (Alter: 26)", Locale.GERMAN, ("6. Aug.", 26)), 79 | ("6 ago (Edad: 26)", Locale.SPANISH_EU, ("6 ago", 26)), 80 | ("6 ago (Edad: 26)", Locale.SPANISH_LATIN, ("6 ago", 26)), 81 | ("6 août (Âge : 26 ans)", Locale.FRENCH, ("6 août", 26)), 82 | ("6 ago (Età: 26)", Locale.ITALIANO, ("6 ago", 26)), 83 | ("8月6日 (年齢: 26)", Locale.JAPANESE, ("8月6日", 26)), 84 | ("8월 6일 (나이: 26세)", Locale.KOREAN, ("8월 6일", 26)), 85 | ("6 sie (Wiek: 26 lat)", Locale.POLISH, ("6 sie", 26)), 86 | ("6 de ago. (Idade: 26)", Locale.PORTUGUESE_BRAZIL, ("6 de ago.", 26)), 87 | ("6 авг. (Возраст: 26)", Locale.RUSSIAN, ("6 авг.", 26)), 88 | ("8月6日 (年齡:26)", Locale.CHINESE_TAIWAN, ("8月6日", 26)), 89 | # Invalid case 90 | ("Unknown", Locale.ENGLISH_US, (None, None)), 91 | ], 92 | ) 93 | def test_get_birthday_and_age( 94 | hero_parser: HeroParser, 95 | input_str: str, 96 | locale: Locale, 97 | result: tuple[str | None, int | None], 98 | ): 99 | """Get birthday and age from text for a given hero""" 100 | assert hero_parser._HeroParser__get_birthday_and_age(input_str, locale) == result 101 | -------------------------------------------------------------------------------- /tests/heroes/parsers/test_heroes_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from fastapi import status 5 | 6 | from app.exceptions import OverfastError 7 | from app.heroes.enums import HeroKey 8 | from app.heroes.parsers.heroes_parser import HeroesParser 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_heroes_page_parsing(heroes_parser: HeroesParser, heroes_html_data: str): 13 | with patch( 14 | "httpx.AsyncClient.get", 15 | return_value=Mock(status_code=status.HTTP_200_OK, text=heroes_html_data), 16 | ): 17 | try: 18 | await heroes_parser.parse() 19 | except OverfastError: 20 | pytest.fail("Heroes list parsing failed") 21 | 22 | assert all(hero["key"] in iter(HeroKey) for hero in heroes_parser.data) 23 | -------------------------------------------------------------------------------- /tests/heroes/test_heroes_route.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from fastapi import status 5 | from fastapi.testclient import TestClient 6 | 7 | from app.config import settings 8 | from app.roles.enums import Role 9 | 10 | 11 | @pytest.fixture(scope="module", autouse=True) 12 | def _setup_heroes_test(heroes_html_data: str): 13 | with patch( 14 | "httpx.AsyncClient.get", 15 | return_value=Mock(status_code=status.HTTP_200_OK, text=heroes_html_data), 16 | ): 17 | yield 18 | 19 | 20 | def test_get_heroes(client: TestClient): 21 | response = client.get("/heroes") 22 | assert response.status_code == status.HTTP_200_OK 23 | assert len(response.json()) > 0 24 | 25 | 26 | @pytest.mark.parametrize("role", [r.value for r in Role]) 27 | def test_get_heroes_filter_by_role(client: TestClient, role: Role): 28 | response = client.get(f"/heroes?role={role}") 29 | assert response.status_code == status.HTTP_200_OK 30 | assert all(hero["role"] == role for hero in response.json()) 31 | 32 | 33 | def test_get_heroes_invalid_role(client: TestClient): 34 | response = client.get("/heroes?role=invalid") 35 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 36 | 37 | 38 | def test_get_heroes_blizzard_error(client: TestClient): 39 | with patch( 40 | "httpx.AsyncClient.get", 41 | return_value=Mock( 42 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 43 | text="Service Unavailable", 44 | ), 45 | ): 46 | response = client.get("/heroes") 47 | 48 | assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT 49 | assert response.json() == { 50 | "error": "Couldn't get Blizzard page (HTTP 503 error) : Service Unavailable", 51 | } 52 | 53 | 54 | def test_get_heroes_internal_error(client: TestClient): 55 | with patch( 56 | "app.heroes.controllers.list_heroes_controller.ListHeroesController.process_request", 57 | return_value=[{"invalid_key": "invalid_value"}], 58 | ): 59 | response = client.get("/heroes") 60 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 61 | assert response.json() == {"error": settings.internal_server_error_message} 62 | 63 | 64 | def test_get_heroes_blizzard_forbidden_error(client: TestClient): 65 | with patch( 66 | "httpx.AsyncClient.get", 67 | return_value=Mock( 68 | status_code=status.HTTP_403_FORBIDDEN, 69 | text="403 Forbidden", 70 | ), 71 | ): 72 | response = client.get("/heroes") 73 | 74 | assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS 75 | assert response.json() == { 76 | "error": ( 77 | "API has been rate limited by Blizzard, please wait for " 78 | f"{settings.blizzard_rate_limit_retry_after} seconds before retrying" 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /tests/maps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/maps/__init__.py -------------------------------------------------------------------------------- /tests/maps/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/maps/parsers/__init__.py -------------------------------------------------------------------------------- /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/maps/parsers/test_maps_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.exceptions import OverfastError 4 | from app.maps.parsers.maps_parser import MapsParser 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_maps_page_parsing(maps_parser: MapsParser): 9 | try: 10 | await maps_parser.parse() 11 | except OverfastError: 12 | pytest.fail("Maps list parsing failed") 13 | 14 | # Just check the format of the first map in the list 15 | assert maps_parser.data[0] == { 16 | "name": "Hanamura", 17 | "screenshot": "https://overfast-api.tekrop.fr/static/maps/hanamura.jpg", 18 | "gamemodes": ["assault"], 19 | "location": "Tokyo, Japan", 20 | "country_code": "JP", 21 | } 22 | -------------------------------------------------------------------------------- /tests/maps/test_maps_route.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from fastapi.testclient import TestClient 4 | 5 | from app.gamemodes.enums import MapGamemode 6 | 7 | 8 | def test_get_maps(client: TestClient): 9 | response = client.get("/maps") 10 | assert response.status_code == status.HTTP_200_OK 11 | assert len(response.json()) > 0 12 | 13 | 14 | @pytest.mark.parametrize("gamemode", [g.value for g in MapGamemode]) 15 | def test_get_maps_filter_by_gamemode(client: TestClient, gamemode: MapGamemode): 16 | response = client.get(f"/maps?gamemode={gamemode}") 17 | assert response.status_code == status.HTTP_200_OK 18 | assert all(gamemode in map_dict["gamemodes"] for map_dict in response.json()) 19 | 20 | 21 | def test_get_maps_invalid_gamemode(client: TestClient): 22 | response = client.get("/maps?gamemode=invalid") 23 | assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 24 | -------------------------------------------------------------------------------- /tests/players/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/players/__init__.py -------------------------------------------------------------------------------- /tests/players/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from _pytest.fixtures import SubRequest 6 | from fastapi import status 7 | 8 | from tests.helpers import read_html_file, read_json_file 9 | 10 | 11 | @pytest.fixture(scope="package") 12 | def player_html_data(request: SubRequest) -> str: 13 | return read_html_file(f"players/{request.param}.html") 14 | 15 | 16 | @pytest.fixture(scope="package") 17 | def search_players_blizzard_json_data() -> list[dict]: 18 | return read_json_file("search_players_blizzard_result.json") 19 | 20 | 21 | @pytest.fixture(scope="package") 22 | def blizzard_unlock_json_data() -> list[dict]: 23 | return read_json_file("blizzard_unlock_data.json") 24 | 25 | 26 | @pytest.fixture(scope="package") 27 | def search_data_json_data() -> list: 28 | return read_json_file("formatted_search_data.json") 29 | 30 | 31 | @pytest.fixture(scope="package") 32 | def player_search_response_mock(search_players_blizzard_json_data: list[dict]) -> Mock: 33 | return Mock( 34 | status_code=status.HTTP_200_OK, 35 | text=json.dumps(search_players_blizzard_json_data), 36 | json=lambda: search_players_blizzard_json_data, 37 | ) 38 | 39 | 40 | @pytest.fixture(scope="package") 41 | def blizzard_unlock_response_mock(blizzard_unlock_json_data: list[dict]) -> Mock: 42 | return Mock( 43 | status_code=status.HTTP_200_OK, 44 | text=json.dumps(blizzard_unlock_json_data), 45 | json=lambda: blizzard_unlock_json_data, 46 | ) 47 | -------------------------------------------------------------------------------- /tests/players/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/players/parsers/__init__.py -------------------------------------------------------------------------------- /tests/players/parsers/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from _pytest.fixtures import SubRequest 3 | 4 | from app.players.parsers.player_career_parser import PlayerCareerParser 5 | from app.players.parsers.player_career_stats_parser import PlayerCareerStatsParser 6 | from app.players.parsers.player_stats_summary_parser import PlayerStatsSummaryParser 7 | 8 | 9 | @pytest.fixture(scope="package") 10 | def player_career_parser(request: SubRequest) -> PlayerCareerParser: 11 | return PlayerCareerParser(player_id=request.param) 12 | 13 | 14 | @pytest.fixture(scope="package") 15 | def player_stats_summary_parser(request: SubRequest) -> PlayerStatsSummaryParser: 16 | return PlayerStatsSummaryParser(player_id=request.param) 17 | 18 | 19 | @pytest.fixture(scope="package") 20 | def player_career_stats_parser(request: SubRequest) -> PlayerCareerStatsParser: 21 | return PlayerCareerStatsParser(player_id=request.param) 22 | -------------------------------------------------------------------------------- /tests/players/parsers/test_player_career_stats_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from fastapi import status 5 | 6 | from app.exceptions import ParserBlizzardError 7 | from app.players.parsers.player_career_stats_parser import PlayerCareerStatsParser 8 | from tests.helpers import players_ids, unknown_player_id 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("player_career_stats_parser", "player_html_data"), 13 | [(player_id, player_id) for player_id in players_ids], 14 | indirect=[ 15 | "player_career_stats_parser", 16 | "player_html_data", 17 | ], 18 | ) 19 | @pytest.mark.asyncio 20 | async def test_player_page_parsing_with_filters( 21 | player_career_stats_parser: PlayerCareerStatsParser, 22 | player_html_data: str, 23 | player_search_response_mock: Mock, 24 | blizzard_unlock_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 | # Unlocks response 32 | blizzard_unlock_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"), 47 | [(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_search_response_mock: Mock, 54 | ): 55 | with ( 56 | pytest.raises(ParserBlizzardError), 57 | patch( 58 | "httpx.AsyncClient.get", 59 | return_value=player_search_response_mock, 60 | ), 61 | ): 62 | await player_career_stats_parser.parse() 63 | -------------------------------------------------------------------------------- /tests/players/parsers/test_player_stats_summary_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from fastapi import status 5 | 6 | from app.exceptions import ParserBlizzardError 7 | from app.players.parsers.player_stats_summary_parser import PlayerStatsSummaryParser 8 | from tests.helpers import players_ids, unknown_player_id 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("player_stats_summary_parser", "player_html_data"), 13 | [(player_id, player_id) for player_id in players_ids], 14 | indirect=[ 15 | "player_stats_summary_parser", 16 | "player_html_data", 17 | ], 18 | ) 19 | @pytest.mark.asyncio 20 | async def test_player_page_parsing( 21 | player_stats_summary_parser: PlayerStatsSummaryParser, 22 | player_html_data: str, 23 | player_search_response_mock: Mock, 24 | blizzard_unlock_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 | # Unlocks response 32 | blizzard_unlock_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"), 44 | [(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_search_response_mock: Mock, 51 | ): 52 | with ( 53 | pytest.raises(ParserBlizzardError), 54 | patch( 55 | "httpx.AsyncClient.get", 56 | return_value=player_search_response_mock, 57 | ), 58 | ): 59 | await player_stats_summary_parser.parse() 60 | -------------------------------------------------------------------------------- /tests/players/test_player_summary_route.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from fastapi import status 5 | from fastapi.testclient import TestClient 6 | from httpx import TimeoutException 7 | 8 | from app.config import settings 9 | from tests.helpers import players_ids 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ("player_id", "player_html_data"), 14 | [(player_id, player_id) for player_id in players_ids], 15 | indirect=["player_html_data"], 16 | ) 17 | def test_get_player_summary( 18 | client: TestClient, 19 | player_id: str, 20 | player_html_data: str, 21 | player_search_response_mock: Mock, 22 | blizzard_unlock_response_mock: Mock, 23 | ): 24 | with patch( 25 | "httpx.AsyncClient.get", 26 | side_effect=[ 27 | # Players search call first 28 | player_search_response_mock, 29 | # UnlocksManager call 30 | blizzard_unlock_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 | -------------------------------------------------------------------------------- /tests/roles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/roles/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/roles/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeKrop/overfast-api/a42ab006f3d2fcb932d13a4e23ef9073e1307d6e/tests/roles/parsers/__init__.py -------------------------------------------------------------------------------- /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/roles/parsers/test_roles_parser.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | from fastapi import status 5 | 6 | from app.exceptions import OverfastError 7 | from app.roles.enums import Role 8 | from app.roles.parsers.roles_parser import RolesParser 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_roles_page_parsing(roles_parser: RolesParser, 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 | try: 18 | await roles_parser.parse() 19 | except OverfastError: 20 | pytest.fail("Roles list parsing failed") 21 | 22 | assert {role["key"] for role in roles_parser.data} == {r.value for r in Role} 23 | -------------------------------------------------------------------------------- /tests/roles/test_roles_route.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from fastapi import status 4 | from fastapi.testclient import TestClient 5 | 6 | from app.config import settings 7 | 8 | 9 | def test_get_roles(client: TestClient, home_html_data: str): 10 | with patch( 11 | "httpx.AsyncClient.get", 12 | return_value=Mock(status_code=status.HTTP_200_OK, text=home_html_data), 13 | ): 14 | response = client.get("/roles") 15 | assert response.status_code == status.HTTP_200_OK 16 | 17 | 18 | def test_get_roles_blizzard_error(client: TestClient): 19 | with patch( 20 | "httpx.AsyncClient.get", 21 | return_value=Mock( 22 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 23 | text="Service Unavailable", 24 | ), 25 | ): 26 | response = client.get("/roles") 27 | 28 | assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT 29 | assert response.json() == { 30 | "error": "Couldn't get Blizzard page (HTTP 503 error) : Service Unavailable", 31 | } 32 | 33 | 34 | def test_get_roles_internal_error(client: TestClient): 35 | with patch( 36 | "app.roles.controllers.list_roles_controller.ListRolesController.process_request", 37 | return_value=[{"invalid_key": "invalid_value"}], 38 | ): 39 | response = client.get("/roles") 40 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 41 | assert response.json() == {"error": settings.internal_server_error_message} 42 | 43 | 44 | def test_get_roles_blizzard_forbidden_error(client: TestClient): 45 | with patch( 46 | "httpx.AsyncClient.get", 47 | return_value=Mock( 48 | status_code=status.HTTP_403_FORBIDDEN, 49 | text="403 Forbidden", 50 | ), 51 | ): 52 | response = client.get("/roles") 53 | 54 | assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS 55 | assert response.json() == { 56 | "error": ( 57 | "API has been rate limited by Blizzard, please wait for " 58 | f"{settings.blizzard_rate_limit_retry_after} seconds before retrying" 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /tests/test_cache_manager.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from fastapi import Request 6 | from redis.exceptions import RedisError 7 | 8 | from app.cache_manager import CacheManager 9 | from app.config import settings 10 | from app.enums import Locale 11 | 12 | 13 | @pytest.fixture 14 | def cache_manager(): 15 | return CacheManager() 16 | 17 | 18 | @pytest.fixture 19 | def locale(): 20 | return Locale.ENGLISH_US 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ("req", "expected"), 25 | [ 26 | (Mock(url=Mock(path="/heroes"), query_params=None), "/heroes"), 27 | ( 28 | Mock(url=Mock(path="/heroes"), query_params="role=damage"), 29 | "/heroes?role=damage", 30 | ), 31 | ( 32 | Mock(url=Mock(path="/players"), query_params="name=TeKrop"), 33 | "/players?name=TeKrop", 34 | ), 35 | ], 36 | ) 37 | def test_get_cache_key_from_request( 38 | cache_manager: CacheManager, 39 | req: Request, 40 | expected: str, 41 | ): 42 | assert cache_manager.get_cache_key_from_request(req) == expected 43 | 44 | 45 | @pytest.mark.parametrize( 46 | ("cache_key", "value", "expire", "sleep_time", "expected"), 47 | [ 48 | ("/heroes", [{"name": "Sojourn"}], 10, 0, [{"name": "Sojourn"}]), 49 | ("/heroes", [{"name": "Sojourn"}], 1, 1, None), 50 | ], 51 | ) 52 | def test_update_and_get_api_cache( 53 | cache_manager: CacheManager, 54 | cache_key: str, 55 | value: list, 56 | expire: int, 57 | sleep_time: int | None, 58 | expected: str | None, 59 | ): 60 | # Assert the value is not here before update 61 | assert cache_manager.get_api_cache(cache_key) is None 62 | 63 | # Update the API Cache and sleep if needed 64 | cache_manager.update_api_cache(cache_key, value, expire) 65 | sleep(sleep_time + 1) 66 | 67 | # Assert the value matches 68 | assert cache_manager.get_api_cache(cache_key) == expected 69 | assert cache_manager.get_api_cache("another_cache_key") is None 70 | 71 | 72 | def test_redis_connection_error(cache_manager: CacheManager): 73 | redis_connection_error = RedisError( 74 | "Error 111 connecting to 127.0.0.1:6379. Connection refused.", 75 | ) 76 | heroes_cache_key = ( 77 | f"HeroesParser-{settings.blizzard_host}/{locale}{settings.heroes_path}" 78 | ) 79 | with patch( 80 | "app.cache_manager.redis.Redis.get", 81 | side_effect=redis_connection_error, 82 | ): 83 | cache_manager.update_api_cache( 84 | heroes_cache_key, 85 | [{"name": "Sojourn"}], 86 | settings.heroes_path_cache_timeout, 87 | ) 88 | assert cache_manager.get_api_cache(heroes_cache_key) is None 89 | 90 | 91 | def test_search_data_update_and_get(cache_manager: CacheManager): 92 | assert cache_manager.get_unlock_data_cache("key") != "value" 93 | 94 | # Insert search data only for one data type 95 | cache_manager.update_unlock_data_cache({"key": "value"}) 96 | 97 | # Check we can retrieve the data by querying for this type 98 | assert cache_manager.get_unlock_data_cache("key") == "value" 99 | assert not cache_manager.get_unlock_data_cache("other_key") 100 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_documentation_route.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from fastapi import status 4 | from fastapi.testclient import TestClient 5 | 6 | 7 | def test_get_redoc_documentation(client: TestClient): 8 | response = client.get("/") 9 | assert response.status_code == status.HTTP_200_OK 10 | assert ( 11 | re.search("(.*)", response.text, re.IGNORECASE)[1] 12 | == "OverFast API - Documentation" 13 | ) 14 | 15 | 16 | def test_get_swagger_documentation(client: TestClient): 17 | response = client.get("/docs") 18 | assert response.status_code == status.HTTP_200_OK 19 | assert ( 20 | re.search("(.*)", response.text, re.IGNORECASE)[1] 21 | == "OverFast API - Documentation" 22 | ) 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | --------------------------------------------------------------------------------