├── .codacy.yml ├── .coveragerc ├── .dockerignore ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ └── python-app.yml ├── .gitignore ├── .pylintrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── assets └── images │ ├── structure.svg │ └── swagger.png ├── codecov.yml ├── commitlint.config.mjs ├── compose.yaml ├── databases ├── __init__.py └── player_database.py ├── main.py ├── models ├── __init__.py └── player_model.py ├── postman_collections └── python-samples-fastapi-restful.postman_collection.json ├── pyproject.toml ├── requirements-lint.txt ├── requirements-test.txt ├── requirements.txt ├── routes ├── __init__.py ├── health_route.py └── player_route.py ├── runtime.txt ├── schemas ├── __init__.py └── player_schema.py ├── scripts ├── entrypoint.sh └── healthcheck.sh ├── services ├── __init__.py └── player_service.py ├── storage └── players-sqlite3.db └── tests ├── __init__.py ├── conftest.py ├── player_stub.py └── test_main.py /.codacy.yml: -------------------------------------------------------------------------------- 1 | # https://docs.codacy.com/repositories-configure/codacy-configuration-file/ 2 | 3 | exclude_paths: 4 | - "assets/**/*" 5 | - "databases/**/*" 6 | - "models/**/*" 7 | - "postman_collections/**/*" 8 | - "schemas/**/*" 9 | - "tests/**/*" 10 | - "**/*.yml" 11 | - "**/*.json" 12 | - "**/*.txt" 13 | - "**/__pycache__/" 14 | - "**/*.pyc" 15 | - "LICENSE" 16 | - "main.py" 17 | - "README.md" 18 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | concurrency = thread,gevent 3 | omit = 4 | */__init__.py 5 | main.py 6 | */data/* 7 | */models/* 8 | */schemas/* 9 | */tests/* 10 | 11 | [report] 12 | exclude_lines = 13 | pragma: no cover # Standard pragma to intentionally skip lines 14 | if __name__ == .__main__.: # Skips CLI bootstrapping code 15 | raise NotImplementedError # Often placeholder stubs not meant to be covered 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .git/ 3 | .github/ 4 | .pytest_cache/ 5 | .venv/ 6 | .vscode/ 7 | htmlcov/ 8 | postman-collections/ 9 | .codacy.yml 10 | .coverage 11 | .coveragerc 12 | .flake8 13 | .gitignore 14 | .pylintrc 15 | CODE_OF_CONDUCT.md 16 | codecov.yml 17 | commitlint.config.mjs 18 | CONTRIBUTING.md 19 | coverage.xml 20 | LICENSE 21 | /tests/ 22 | __pycache__/ 23 | *.pyc 24 | *.pyo 25 | *.pyd 26 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | max-complexity = 10 4 | select = E,F,W 5 | extend-ignore = E203, W503 6 | exclude = .venv 7 | per-file-ignores = tests/test_main.py: E501 8 | count = True 9 | show-source = True 10 | statistics = True 11 | verbose = True 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "pip" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | groups: 10 | fastapi: 11 | patterns: 12 | - "fastapi*" 13 | flake8: 14 | patterns: 15 | - "flake8*" 16 | pytest: 17 | patterns: 18 | - "pytest*" 19 | - "gevent" 20 | - package-ecosystem: "github-actions" 21 | directory: "/" 22 | schedule: 23 | interval: "daily" 24 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # Building and testing Python 2 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python CI 5 | 6 | permissions: 7 | contents: read 8 | 9 | on: 10 | push: 11 | branches: [ master ] 12 | pull_request: 13 | branches: [ master ] 14 | 15 | env: 16 | PYTHON_VERSION: 3.13.3 17 | 18 | jobs: 19 | lint: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4.2.2 24 | 25 | - name: Lint commit messages 26 | uses: wagoid/commitlint-github-action@v6.2.1 27 | 28 | - name: Set up Python ${{ env.PYTHON_VERSION }} 29 | uses: actions/setup-python@v5.6.0 30 | with: 31 | python-version: ${{ env.PYTHON_VERSION }} 32 | cache: 'pip' 33 | 34 | - name: Install lint dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install -r requirements-lint.txt 38 | 39 | - name: Lint with Flake8 40 | run: | 41 | flake8 . 42 | 43 | - name: Check code formatting with Black 44 | run: | 45 | black --check . 46 | 47 | test: 48 | needs: lint 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v4.2.2 53 | 54 | - name: Set up Python ${{ env.PYTHON_VERSION }} 55 | uses: actions/setup-python@v5.6.0 56 | with: 57 | python-version: ${{ env.PYTHON_VERSION }} 58 | cache: 'pip' 59 | 60 | - name: Install test dependencies 61 | run: | 62 | python -m pip install --upgrade pip 63 | pip install -r requirements-test.txt 64 | 65 | - name: Run tests with pytest 66 | run: | 67 | pytest -v 68 | 69 | - name: Generate coverage report 70 | run: | 71 | pytest --cov=./ --cov-report=xml --cov-report=term 72 | 73 | - name: Upload coverage report artifact 74 | uses: actions/upload-artifact@v4.6.2 75 | with: 76 | name: coverage.xml 77 | path: ./coverage.xml 78 | 79 | coverage: 80 | needs: test 81 | runs-on: ubuntu-latest 82 | strategy: 83 | matrix: 84 | service: [codecov, codacy] 85 | steps: 86 | - name: Checkout repository 87 | uses: actions/checkout@v4.2.2 88 | 89 | - name: Download coverage report artifact 90 | uses: actions/download-artifact@v4.3.0 91 | with: 92 | name: coverage.xml 93 | 94 | - name: Upload coverage report to ${{ matrix.service }} 95 | if: ${{ matrix.service == 'codecov' }} 96 | uses: codecov/codecov-action@v5.4.3 97 | with: 98 | token: ${{ secrets.CODECOV_TOKEN }} 99 | files: coverage.xml 100 | 101 | - name: Upload coverage report to ${{ matrix.service }} 102 | if: ${{ matrix.service == 'codacy' }} 103 | uses: codacy/codacy-coverage-reporter-action@v1.3.0 104 | with: 105 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 106 | coverage-reports: coverage.xml 107 | 108 | container: 109 | needs: coverage 110 | runs-on: ubuntu-latest 111 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} 112 | 113 | permissions: 114 | contents: read 115 | packages: write 116 | 117 | steps: 118 | - name: Checkout repository 119 | uses: actions/checkout@v4.2.2 120 | 121 | - name: Log in to GitHub Container Registry 122 | uses: docker/login-action@v3.4.0 123 | with: 124 | registry: ghcr.io 125 | username: ${{ github.actor }} 126 | password: ${{ secrets.GITHUB_TOKEN }} 127 | 128 | - name: Set up Docker Buildx 129 | uses: docker/setup-buildx-action@v3.10.0 130 | 131 | - name: Build and push Docker image to GitHub Container Registry 132 | uses: docker/build-push-action@v6.18.0 133 | with: 134 | context: . 135 | push: true 136 | platforms: linux/amd64 137 | provenance: false 138 | cache-from: type=gha 139 | cache-to: type=gha,mode=max 140 | tags: | 141 | ghcr.io/${{ github.repository }}:latest 142 | ghcr.io/${{ github.repository }}:sha-${{ github.sha }} 143 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | coverage.lcov 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable= 3 | C0114, # missing-module-docstring 4 | C0301, # line-too-long 5 | R0902, # too-many-instance-attributes 6 | R0903, # too-few-public-methods 7 | R0913, # too-many-arguments 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.python", 4 | "ms-python.flake8", 5 | "ms-python.black-formatter", 6 | "github.vscode-pull-request-github", 7 | "github.vscode-github-actions", 8 | "ms-azuretools.vscode-containers", 9 | "redhat.vscode-yaml", 10 | "sonarsource.sonarlint-vscode", 11 | "esbenp.prettier-vscode", 12 | "conventionalcommits.extension", 13 | "codezombiech.gitignore" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Launch FastAPI (Debug)", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "module": "uvicorn", 9 | "args": ["main:app", "--reload", "--port", "9000"], 10 | "jinja": true, 11 | "serverReadyAction": { 12 | "action": "openExternally", 13 | "pattern": "Uvicorn running on .*:(\\d+)", 14 | "uriFormat": "http://localhost:%s/docs" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/__pycache__": true, 4 | "**/.git": true, 5 | "**/.DS_Store": true 6 | }, 7 | "editor.wordWrapColumn": 88, 8 | "editor.rulers": [88], 9 | "[python]": { 10 | "editor.defaultFormatter": "ms-python.black-formatter", 11 | "editor.formatOnSave": true 12 | }, 13 | "python.testing.unittestEnabled": false, 14 | "python.testing.pytestEnabled": true, 15 | "python.testing.pytestArgs": ["tests"], 16 | "flake8.enabled": true, 17 | "flake8.importStrategy": "fromEnvironment", 18 | "flake8.path": ["${interpreter}", "-m", "flake8"], 19 | // Point flake8 to use your existing config file automatically 20 | "flake8.args": [ 21 | "--max-line-length=88", 22 | "--max-complexity=10", 23 | "--select=E,F,W", 24 | "--extend-ignore=E203,W503", 25 | "--exclude=.venv", 26 | "--per-file-ignores=tests/test_main.py:E501" 27 | ], 28 | // Exclude files/folders you don’t want to lint (matching Black’s exclude) 29 | "flake8.ignorePatterns": [ 30 | "**/.git/**", 31 | "**/.github/**", 32 | "**/.pytest_cache/**", 33 | "**/.venv/**", 34 | "**/.vscode/**", 35 | "**/assets/**", 36 | "**/htmlcov/**", 37 | "**/postman_collections/**", 38 | "**/scripts/**", 39 | "**/storage/**", 40 | "**/__pycache__/**", 41 | "**/tests/test_main.py" 42 | ], 43 | "flake8.severity": { 44 | "convention": "Information", 45 | "error": "Error", 46 | "fatal": "Error", 47 | "refactor": "Hint", 48 | "warning": "Warning", 49 | "info": "Information" 50 | }, 51 | "sonarlint.connectedMode.project": { 52 | "connectionId": "nanotaboada", 53 | "projectKey": "nanotaboada_python-samples-fastapi-restful" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /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 | . 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 | . 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 | . Translations are available at 128 | . 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for improving this project! We value small, precise changes that solve real problems. 4 | We value **incremental, detail‑first contributions** over big rewrites or abstractions. 5 | 6 | ## 1. Philosophy 7 | 8 | > "Nobody should start to undertake a large project. You start with a small _trivial_ project, and you should never expect it to get large. If you do, you'll just overdesign and generally think it is more important than it likely is at that stage. Or worse, you might be scared away by the sheer size of the work you envision. So start small, and think about the details. Don't think about some big picture and fancy design. If it doesn't solve some fairly immediate need, it's almost certainly over-designed. And don't expect people to jump in and help you. That's not how these things work. You need to get something half-way _useful_ first, and then others will say "hey, that _almost_ works for me", and they'll get involved in the project." — [Linus Torvalds](https://web.archive.org/web/20050404020308/http://www.linuxtimes.net/modules.php?name=News&file=article&sid=145) 9 | 10 | ## 2. Code & Commit Conventions 11 | 12 | - **Conventional Commits** 13 | Follow : 14 | - `feat: ` for new features 15 | - `fix: ` for bug fixes 16 | - `chore: ` for maintenance or tooling 17 | 18 | - **Logical Commits** 19 | Group changes by purpose. Multiple commits are fine, but avoid noise. Squash when appropriate. 20 | 21 | - **Python Formatting & Style** 22 | - Use **[Black](https://black.readthedocs.io/)** for consistent code formatting. 23 | - Black is opinionated: don't argue with it, just run it. 24 | - Line length is set to **88**, matching the default. 25 | - Use **[flake8](https://flake8.pycqa.org/en/latest/)** for static checks. 26 | - Line length also set to 88. 27 | - Some flake8 warnings are disabled (e.g. `E203`, `W503`) to avoid conflicts with Black. 28 | - Run `black .` and `flake8` before submitting. 29 | - Use Python **3.13.x** for local testing and formatting. 30 | 31 | - **Testing** 32 | - Run `pytest` before pushing. 33 | - Ensure coverage isn’t regressing. 34 | 35 | ## 3. Pull Request Workflow 36 | 37 | - **One logical change per PR.** 38 | - **Rebase or squash** before opening to keep history clean. 39 | - **Title & Description** 40 | - Use Conventional Commit format. 41 | - Explain _what_ and _why_ concisely in the PR body. 42 | 43 | ## 4. Issue Reporting 44 | 45 | - Search open issues before creating a new one. 46 | - Include clear steps to reproduce and environment details. 47 | - Prefer **focused** issues—don’t bundle multiple topics. 48 | 49 | ## 5. Automation & Checks 50 | 51 | All PRs and pushes go through CI: 52 | 53 | - **Commitlint** for commit style 54 | - **Black** for formatting 55 | - **flake8** for static checks 56 | - **pytest** with coverage 57 | 58 | PRs must pass all checks to be reviewed. 59 | 60 | ## 6. Code of Conduct & Support 61 | 62 | - See `CODE_OF_CONDUCT.md` for guidelines and reporting. 63 | - For questions or planning, open an issue and use the `discussion` label, or mention a maintainer. 64 | 65 | --- 66 | 67 | Thanks again for helping keep this project small, sharp, and focused. 68 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Stage 1: Builder 3 | # This stage builds the application and its dependencies. 4 | # ------------------------------------------------------------------------------ 5 | FROM python:3.13.3-slim-bookworm AS builder 6 | 7 | WORKDIR /app 8 | 9 | # Install system build tools for packages with native extensions 10 | RUN apt-get update && \ 11 | apt-get install -y --no-install-recommends build-essential gcc libffi-dev libssl-dev && \ 12 | rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb 13 | 14 | # Build all dependencies into wheels for reproducibility and speed 15 | COPY --chown=root:root --chmod=644 requirements.txt . 16 | RUN pip wheel --no-cache-dir --wheel-dir=/app/wheelhouse -r requirements.txt 17 | 18 | # ------------------------------------------------------------------------------ 19 | # Stage 2: Runtime 20 | # This stage creates the final, minimal image to run the application. 21 | # ------------------------------------------------------------------------------ 22 | FROM python:3.13.3-slim-bookworm AS runtime 23 | 24 | WORKDIR /app 25 | 26 | # Install curl for health check 27 | RUN apt-get update && apt-get install -y --no-install-recommends curl && \ 28 | rm -rf /var/lib/apt/lists/* 29 | 30 | # Add metadata labels 31 | LABEL org.opencontainers.image.title="🧪 RESTful API with Python 3 and FastAPI" 32 | LABEL org.opencontainers.image.description="Proof of Concept for a RESTful API made with Python 3 and FastAPI" 33 | LABEL org.opencontainers.image.licenses="MIT" 34 | LABEL org.opencontainers.image.source="https://github.com/nanotaboada/python-samples-fastapi-restful" 35 | 36 | # Copy metadata docs for container registries (e.g.: GitHub Container Registry) 37 | COPY README.md ./ 38 | COPY assets/ ./assets/ 39 | 40 | # Copy pre-built wheels from builder 41 | COPY --from=builder /app/wheelhouse/ /app/wheelhouse/ 42 | 43 | # Install dependencies 44 | COPY requirements.txt . 45 | RUN pip install --no-cache-dir --no-index --find-links /app/wheelhouse -r requirements.txt && \ 46 | rm -rf /app/wheelhouse 47 | 48 | # Copy application source code 49 | COPY main.py ./ 50 | COPY databases/ ./databases/ 51 | COPY models/ ./models/ 52 | COPY routes/ ./routes/ 53 | COPY schemas/ ./schemas/ 54 | COPY services/ ./services/ 55 | 56 | # https://rules.sonarsource.com/docker/RSPEC-6504/ 57 | 58 | # Copy entrypoint and healthcheck scripts 59 | COPY --chmod=755 scripts/entrypoint.sh ./entrypoint.sh 60 | COPY --chmod=755 scripts/healthcheck.sh ./healthcheck.sh 61 | # The 'hold' is our storage compartment within the image. Here, we copy a 62 | # pre-seeded SQLite database file, which Compose will mount as a persistent 63 | # 'storage' volume when the container starts up. 64 | COPY --chmod=755 storage/ ./hold/ 65 | 66 | # Add non-root user and make volume mount point writable 67 | RUN adduser --system --disabled-password --group fastapi && \ 68 | mkdir -p /storage && \ 69 | chown fastapi:fastapi /storage 70 | 71 | ENV PYTHONUNBUFFERED=1 72 | 73 | USER fastapi 74 | 75 | EXPOSE 9000 76 | 77 | HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ 78 | CMD ["./healthcheck.sh"] 79 | 80 | ENTRYPOINT ["./entrypoint.sh"] 81 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9000"] 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nano Taboada 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧪 RESTful API with Python 3 and FastAPI 2 | 3 | ## Status 4 | 5 | [![Python CI](https://github.com/nanotaboada/python-samples-fastapi-restful/actions/workflows/python-app.yml/badge.svg)](https://github.com/nanotaboada/python-samples-fastapi-restful/actions/workflows/python-app.yml) 6 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nanotaboada_python-samples-fastapi-restful&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=nanotaboada_python-samples-fastapi-restful) 7 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/8f9bab37f6f444c895a8b25d5df772fc)](https://app.codacy.com/gh/nanotaboada/python-samples-fastapi-restful/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) 8 | [![codecov](https://codecov.io/gh/nanotaboada/python-samples-fastapi-restful/branch/master/graph/badge.svg?token=A1WNZPRQEJ)](https://codecov.io/gh/nanotaboada/python-samples-fastapi-restful) 9 | [![CodeFactor](https://www.codefactor.io/repository/github/nanotaboada/python-samples-fastapi-restful/badge)](https://www.codefactor.io/repository/github/nanotaboada/python-samples-fastapi-restful) 10 | [![codebeat badge](https://codebeat.co/badges/4c4f7c08-3b35-4b57-a875-bf2043efe515)](https://codebeat.co/projects/github-com-nanotaboada-python-samples-fastapi-restful-master) 11 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 12 | 13 | ## About 14 | 15 | Proof of Concept for a RESTful API made with [Python 3](https://www.python.org/) and [FastAPI](https://fastapi.tiangolo.com/). 16 | 17 | ## Structure 18 | 19 | ![Simplified, conceptual project structure and main application flow](assets/images/structure.svg) 20 | 21 | _Figure: Simplified, conceptual project structure and main application flow. Not all dependencies are shown._ 22 | 23 | ## Install 24 | 25 | ```console 26 | pip install -r requirements.txt 27 | pip install -r requirements-lint.txt 28 | pip install -r requirements-test.txt 29 | ``` 30 | 31 | ## Start 32 | 33 | ```console 34 | uvicorn main:app --reload --port 9000 35 | ``` 36 | 37 | ## Docs 38 | 39 | ```console 40 | http://localhost:9000/docs 41 | ``` 42 | 43 | ![API Documentation](assets/images/swagger.png) 44 | 45 | ## Container 46 | 47 | ### Docker Compose 48 | 49 | This setup uses [Docker Compose](https://docs.docker.com/compose/) to build and run the app and manage a persistent SQLite database stored in a Docker volume. 50 | 51 | #### Build the image 52 | 53 | ```bash 54 | docker compose build 55 | ``` 56 | 57 | #### Start the app 58 | 59 | ```bash 60 | docker compose up 61 | ``` 62 | 63 | > On first run, the container copies a pre-seeded SQLite database into a persistent volume 64 | > On subsequent runs, that volume is reused and the data is preserved 65 | 66 | #### Stop the app 67 | 68 | ```bash 69 | docker compose down 70 | ``` 71 | 72 | #### Optional: database reset 73 | 74 | ```bash 75 | docker compose down -v 76 | ``` 77 | 78 | > This removes the volume and will reinitialize the database from the built-in seed file the next time you `up`. 79 | 80 | ## Credits 81 | 82 | The solution has been coded using [Visual Studio Code](https://code.visualstudio.com/) with the official [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) extension. 83 | 84 | ## Terms 85 | 86 | All trademarks, registered trademarks, service marks, product names, company names, or logos mentioned on this repository are the property of their respective owners. All usage of such terms herein is for identification purposes only and constitutes neither an endorsement nor a recommendation of those items. Furthermore, the use of such terms is intended to be for educational and informational purposes only. 87 | -------------------------------------------------------------------------------- /assets/images/structure.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/images/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/assets/images/swagger.png -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # Codecov Repository YAML 2 | # https://docs.codecov.com/docs/codecov-yaml 3 | 4 | coverage: 5 | # https://docs.codecov.com/docs/commit-status 6 | status: 7 | project: 8 | default: 9 | target: 80% 10 | threshold: 10% 11 | if_not_found: success 12 | if_ci_failed: success 13 | patch: 14 | default: 15 | target: 80% 16 | threshold: 10% 17 | if_not_found: success 18 | 19 | # https://docs.codecov.com/docs/components#component-options 20 | component_management: 21 | default_rules: 22 | statuses: 23 | - type: project 24 | target: auto 25 | branches: 26 | - "!main" 27 | individual_components: 28 | - component_id: services 29 | name: Services 30 | paths: 31 | - "services/" 32 | - component_id: routes 33 | name: Routes 34 | paths: 35 | - "routes/" 36 | 37 | comment: 38 | layout: "header, diff, flags, components" 39 | 40 | # https://docs.codecov.com/docs/ignoring-paths 41 | ignore: 42 | - "^assets/.*" 43 | - "^databases/.*" 44 | - "^models/.*" 45 | - "^postman_collections/.*" 46 | - "^schemas/.*" 47 | - "^tests/.*" 48 | - ".*\\.yml$" 49 | - ".*\\.json$" 50 | - ".*\\.txt$" 51 | - "^__pycache__(/.*)?$" 52 | - ".*\\.pyc$" 53 | - "^LICENSE$" 54 | - "^main\\.py$" 55 | - "^README\\.md$" 56 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | import conventional from "@commitlint/config-conventional"; 2 | 3 | export default { 4 | ...conventional, 5 | rules: { 6 | "header-max-length": [2, "always", 80], 7 | "body-max-line-length": [2, "always", 80], 8 | }, 9 | ignores: [ 10 | // bypass Dependabot-style commits 11 | (message) => /^chore\(deps(-dev)?\): bump /.test(message), 12 | (message) => /Signed-off-by: dependabot\[bot\]/.test(message), 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | image: python-samples-fastapi-restful 4 | container_name: fastapi-app 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "9000:9000" 10 | volumes: 11 | - storage:/storage/ 12 | environment: 13 | - PYTHONUNBUFFERED=1 14 | - STORAGE_PATH=/storage/players-sqlite3.db 15 | restart: unless-stopped 16 | 17 | volumes: 18 | storage: 19 | name: python-samples-fastapi-restful_storage 20 | -------------------------------------------------------------------------------- /databases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/databases/__init__.py -------------------------------------------------------------------------------- /databases/player_database.py: -------------------------------------------------------------------------------- 1 | """ 2 | Database setup and session management for async SQLAlchemy with SQLite. 3 | 4 | - Configures the async database engine using `aiosqlite` driver. 5 | - Creates an async sessionmaker for ORM operations. 6 | - Defines the declarative base class for model definitions. 7 | - Provides an async generator dependency to yield database sessions. 8 | 9 | The `STORAGE_PATH` environment variable controls the SQLite file location. 10 | """ 11 | 12 | import logging 13 | import os 14 | from typing import AsyncGenerator 15 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 16 | from sqlalchemy.orm import sessionmaker, declarative_base 17 | 18 | storage_path = os.getenv("STORAGE_PATH", "./storage/players-sqlite3.db") 19 | DATABASE_URL = f"sqlite+aiosqlite:///{storage_path}" 20 | 21 | logger = logging.getLogger("uvicorn") 22 | logging.getLogger("sqlalchemy.engine.Engine").handlers = logger.handlers 23 | 24 | async_engine = create_async_engine( 25 | DATABASE_URL, connect_args={"check_same_thread": False}, echo=True 26 | ) 27 | 28 | async_sessionmaker = sessionmaker( 29 | bind=async_engine, class_=AsyncSession, autocommit=False, autoflush=False 30 | ) 31 | 32 | Base = declarative_base() 33 | 34 | 35 | async def generate_async_session() -> AsyncGenerator[AsyncSession, None]: 36 | """ 37 | Dependency function to yield an async SQLAlchemy ORM session. 38 | 39 | Yields: 40 | AsyncSession: An instance of an async SQLAlchemy ORM session. 41 | """ 42 | async with async_sessionmaker() as async_session: 43 | yield async_session 44 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main application module for the FastAPI RESTful API. 3 | 4 | - Sets up the FastAPI app with metadata (title, description, version). 5 | - Defines the lifespan event handler for app startup/shutdown logging. 6 | - Includes API routers for player and health endpoints. 7 | 8 | This serves as the entry point for running the API server. 9 | """ 10 | 11 | from contextlib import asynccontextmanager 12 | import logging 13 | from typing import AsyncIterator 14 | from fastapi import FastAPI 15 | from routes import player_route, health_route 16 | 17 | # https://github.com/encode/uvicorn/issues/562 18 | UVICORN_LOGGER = "uvicorn.error" 19 | logger = logging.getLogger(UVICORN_LOGGER) 20 | 21 | 22 | @asynccontextmanager 23 | async def lifespan(_: FastAPI) -> AsyncIterator[None]: 24 | """ 25 | Lifespan event handler for FastAPI. 26 | """ 27 | logger.info("Lifespan event handler execution complete.") 28 | yield 29 | 30 | 31 | app = FastAPI( 32 | lifespan=lifespan, 33 | title="python-samples-fastapi-restful", 34 | description="🧪 Proof of Concept for a RESTful API made with Python 3 and FastAPI", 35 | version="1.0.0", 36 | ) 37 | 38 | app.include_router(player_route.api_router) 39 | app.include_router(health_route.api_router) 40 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/models/__init__.py -------------------------------------------------------------------------------- /models/player_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pydantic models defining the data schema for football players. 3 | 4 | - `MainModel`: Base model with common config for camelCase aliasing. 5 | - `PlayerModel`: Represents a football player with personal and team details. 6 | 7 | These models are used for data validation and serialization in the API. 8 | """ 9 | 10 | from typing import Optional 11 | from pydantic import BaseModel, ConfigDict 12 | from pydantic.alias_generators import to_camel 13 | 14 | 15 | class MainModel(BaseModel): 16 | """ 17 | Base model configuration for all Pydantic models in the application. 18 | 19 | This class sets a common configuration for alias generation and name population 20 | for any model that inherits from it. It uses camelCase for JSON field names. 21 | 22 | Attributes: 23 | model_config (ConfigDict): Configuration for Pydantic models, including: 24 | alias_generator (function): A function to generate field aliases. 25 | Here, it uses `to_camel` to convert field names to camelCase. 26 | populate_by_name (bool): Allows population of fields by name when using 27 | Pydantic models. 28 | """ 29 | 30 | model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) 31 | 32 | 33 | class PlayerModel(MainModel): 34 | """ 35 | Pydantic model representing a football Player. 36 | 37 | Attributes: 38 | id (int): The unique identifier for the Player. 39 | first_name (str): The first name of the Player. 40 | middle_name (Optional[str]): The middle name of the Player, if any. 41 | last_name (str): The last name of the Player. 42 | date_of_birth (Optional[str]): The date of birth of the Player, if provided. 43 | squad_number (int): The unique squad number assigned to the Player. 44 | position (str): The playing position of the Player. 45 | abbr_position (Optional[str]): The abbreviated form of the Player's position, 46 | if any. 47 | team (Optional[str]): The team to which the Player belongs, if any. 48 | league (Optional[str]): The league where the team plays, if any. 49 | starting11 (Optional[bool]): Indicates if the Player is in the starting 11, 50 | if provided. 51 | """ 52 | 53 | id: int 54 | first_name: str 55 | middle_name: Optional[str] 56 | last_name: str 57 | date_of_birth: Optional[str] 58 | squad_number: int 59 | position: str 60 | abbr_position: Optional[str] 61 | team: Optional[str] 62 | league: Optional[str] 63 | starting11: Optional[bool] 64 | -------------------------------------------------------------------------------- /postman_collections/python-samples-fastapi-restful.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "a6f69e7a-9b1f-45d9-a7a6-56f3e824d372", 4 | "name": "python-samples-fastapi-restful", 5 | "description": "🧪 Proof of Concept for a RESTful API made with Python 3 and FastAPI\n\n[https://github.com/nanotaboada/python-samples-fastapi-restful](https://github.com/nanotaboada/python-samples-fastapi-restful)", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 7 | "_exporter_id": "32077259" 8 | }, 9 | "item": [ 10 | { 11 | "name": "Create", 12 | "request": { 13 | "method": "POST", 14 | "header": [], 15 | "body": { 16 | "mode": "raw", 17 | "raw": "{\n \"id\": 12,\n \"firstName\": \"Leandro\",\n \"middleName\": \"Daniel\",\n \"lastName\": \"Paredes\",\n \"dateOfBirth\": \"1994-06-29T00:00:00.000Z\",\n \"squadNumber\": 5,\n \"position\": \"Defensive Midfield\",\n \"abbrPosition\": \"DM\",\n \"team\": \"AS Roma\",\n \"league\": \"Serie A\",\n \"starting11\": false\n}", 18 | "options": { 19 | "raw": { 20 | "language": "json" 21 | } 22 | } 23 | }, 24 | "url": { 25 | "raw": "http://localhost:9000/players/", 26 | "protocol": "http", 27 | "host": ["localhost"], 28 | "port": "9000", 29 | "path": ["players"] 30 | }, 31 | "description": "Creates a new Player" 32 | }, 33 | "response": [] 34 | }, 35 | { 36 | "name": "Retrieve", 37 | "protocolProfileBehavior": { 38 | "disableBodyPruning": true 39 | }, 40 | "request": { 41 | "method": "GET", 42 | "header": [], 43 | "body": { 44 | "mode": "raw", 45 | "raw": "", 46 | "options": { 47 | "raw": { 48 | "language": "json" 49 | } 50 | } 51 | }, 52 | "url": { 53 | "raw": "http://localhost:9000/players/", 54 | "protocol": "http", 55 | "host": ["localhost"], 56 | "port": "9000", 57 | "path": ["players"] 58 | }, 59 | "description": "Retrieves all the Players" 60 | }, 61 | "response": [] 62 | }, 63 | { 64 | "name": "Retrieve By Id", 65 | "protocolProfileBehavior": { 66 | "disableBodyPruning": true 67 | }, 68 | "request": { 69 | "method": "GET", 70 | "header": [], 71 | "body": { 72 | "mode": "raw", 73 | "raw": "", 74 | "options": { 75 | "raw": { 76 | "language": "json" 77 | } 78 | } 79 | }, 80 | "url": { 81 | "raw": "http://localhost:9000/players/1", 82 | "protocol": "http", 83 | "host": ["localhost"], 84 | "port": "9000", 85 | "path": ["players", "1"] 86 | }, 87 | "description": "Retrieves one Player by Id" 88 | }, 89 | "response": [] 90 | }, 91 | { 92 | "name": "Retrieve By Squad Number", 93 | "protocolProfileBehavior": { 94 | "disableBodyPruning": true 95 | }, 96 | "request": { 97 | "method": "GET", 98 | "header": [], 99 | "body": { 100 | "mode": "raw", 101 | "raw": "", 102 | "options": { 103 | "raw": { 104 | "language": "json" 105 | } 106 | } 107 | }, 108 | "url": { 109 | "raw": "http://localhost:9000/players/squadnumber/10", 110 | "protocol": "http", 111 | "host": ["localhost"], 112 | "port": "9000", 113 | "path": ["players", "squadnumber", "10"] 114 | }, 115 | "description": "Retrieves one Player by Squad Number" 116 | }, 117 | "response": [] 118 | }, 119 | { 120 | "name": "Update", 121 | "request": { 122 | "method": "PUT", 123 | "header": [], 124 | "body": { 125 | "mode": "raw", 126 | "raw": "{\n \"id\": 12,\n \"firstName\": \"Leandro\",\n \"middleName\": \"Daniel\",\n \"lastName\": \"Paredes\",\n \"dateOfBirth\": \"1994-06-29T00:00:00.000Z\",\n \"squadNumber\": 5,\n \"position\": \"Defensive Midfield\",\n \"abbrPosition\": \"DM\",\n \"team\": \"AS Roma\",\n \"league\": \"Serie A\",\n \"starting11\": true\n}", 127 | "options": { 128 | "raw": { 129 | "language": "json" 130 | } 131 | } 132 | }, 133 | "url": { 134 | "raw": "http://localhost:9000/players/12", 135 | "protocol": "http", 136 | "host": ["localhost"], 137 | "port": "9000", 138 | "path": ["players", "12"] 139 | }, 140 | "description": "Updates an existing Player" 141 | }, 142 | "response": [] 143 | }, 144 | { 145 | "name": "Delete", 146 | "request": { 147 | "method": "DELETE", 148 | "header": [], 149 | "url": { 150 | "raw": "http://localhost:9000/players/12", 151 | "protocol": "http", 152 | "host": ["localhost"], 153 | "port": "9000", 154 | "path": ["players", "12"] 155 | }, 156 | "description": "Deletes an existing Player" 157 | }, 158 | "response": [] 159 | } 160 | ] 161 | } 162 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py313'] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.git 8 | | \.github 9 | | \.pytest_cache 10 | | \.venv 11 | | \.vscode 12 | | assets 13 | | htmlcov 14 | | postman_collections 15 | | scripts 16 | | storage 17 | | __pycache__ 18 | | tests/test_main\.py 19 | )/ 20 | ''' 21 | -------------------------------------------------------------------------------- /requirements-lint.txt: -------------------------------------------------------------------------------- 1 | flake8==7.2.0 2 | black==25.1.0 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | pytest==8.3.5 4 | pytest-cov==6.1.1 5 | pytest-sugar==1.0.0 6 | gevent==25.5.1 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # https://fastapi.tiangolo.com/#standard-dependencies 2 | fastapi[standard]==0.115.12 3 | SQLAlchemy==2.0.41 4 | aiosqlite==0.21.0 5 | aiocache==0.12.3 6 | -------------------------------------------------------------------------------- /routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/routes/__init__.py -------------------------------------------------------------------------------- /routes/health_route.py: -------------------------------------------------------------------------------- 1 | """ 2 | Health check API route. 3 | 4 | Defines a simple endpoint to verify that the service is up and running. 5 | Returns a JSON response with a "status" key set to "ok". 6 | """ 7 | 8 | from fastapi import APIRouter 9 | 10 | api_router = APIRouter() 11 | 12 | 13 | @api_router.get("/health", tags=["Health"]) 14 | async def health_check(): 15 | """ 16 | Simple health check endpoint. 17 | Returns a JSON response with a single key "status" and value "ok". 18 | """ 19 | return {"status": "ok"} 20 | -------------------------------------------------------------------------------- /routes/player_route.py: -------------------------------------------------------------------------------- 1 | """ 2 | API routes for managing Player resources. 3 | 4 | Provides CRUD endpoints to create, read, update, and delete Player entities. 5 | 6 | Features: 7 | - Caching with in-memory cache to optimize retrieval performance. 8 | - Async database session dependency injection. 9 | - Standard HTTP status codes and error handling. 10 | 11 | Endpoints: 12 | - POST /players/ : Create a new Player. 13 | - GET /players/ : Retrieve all Players. 14 | - GET /players/{player_id} : Retrieve Player by ID. 15 | - GET /players/squadnumber/{squad_number} : Retrieve Player by Squad Number. 16 | - PUT /players/{player_id} : Update an existing Player. 17 | - DELETE /players/{player_id} : Delete an existing Player. 18 | """ 19 | 20 | from typing import List 21 | from fastapi import APIRouter, Body, Depends, HTTPException, status, Path, Response 22 | from sqlalchemy.ext.asyncio import AsyncSession 23 | from aiocache import SimpleMemoryCache 24 | 25 | from databases.player_database import generate_async_session 26 | from models.player_model import PlayerModel 27 | from services import player_service 28 | 29 | api_router = APIRouter() 30 | simple_memory_cache = SimpleMemoryCache() 31 | 32 | CACHE_KEY = "players" 33 | CACHE_TTL = 600 # 10 minutes 34 | 35 | # POST ------------------------------------------------------------------------- 36 | 37 | 38 | @api_router.post( 39 | "/players/", 40 | status_code=status.HTTP_201_CREATED, 41 | summary="Creates a new Player", 42 | tags=["Players"], 43 | ) 44 | async def post_async( 45 | player_model: PlayerModel = Body(...), 46 | async_session: AsyncSession = Depends(generate_async_session), 47 | ): 48 | """ 49 | Endpoint to create a new player. 50 | 51 | Args: 52 | player_model (PlayerModel): The Pydantic model representing the Player to 53 | create. 54 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 55 | 56 | Raises: 57 | HTTPException: HTTP 409 Conflict error if the Player already exists. 58 | """ 59 | player = await player_service.retrieve_by_id_async(async_session, player_model.id) 60 | if player: 61 | raise HTTPException(status_code=status.HTTP_409_CONFLICT) 62 | await player_service.create_async(async_session, player_model) 63 | await simple_memory_cache.clear(CACHE_KEY) 64 | 65 | 66 | # GET -------------------------------------------------------------------------- 67 | 68 | 69 | @api_router.get( 70 | "/players/", 71 | response_model=List[PlayerModel], 72 | status_code=status.HTTP_200_OK, 73 | summary="Retrieves a collection of Players", 74 | tags=["Players"], 75 | ) 76 | async def get_all_async( 77 | response: Response, async_session: AsyncSession = Depends(generate_async_session) 78 | ): 79 | """ 80 | Endpoint to retrieve all players. 81 | 82 | Args: 83 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 84 | 85 | Returns: 86 | List[PlayerModel]: A list of Pydantic models representing all players. 87 | """ 88 | players = await simple_memory_cache.get(CACHE_KEY) 89 | response.headers["X-Cache"] = "HIT" 90 | if not players: 91 | players = await player_service.retrieve_all_async(async_session) 92 | await simple_memory_cache.set(CACHE_KEY, players, ttl=CACHE_TTL) 93 | response.headers["X-Cache"] = "MISS" 94 | return players 95 | 96 | 97 | @api_router.get( 98 | "/players/{player_id}", 99 | response_model=PlayerModel, 100 | status_code=status.HTTP_200_OK, 101 | summary="Retrieves a Player by its Id", 102 | tags=["Players"], 103 | ) 104 | async def get_by_id_async( 105 | player_id: int = Path(..., title="The ID of the Player"), 106 | async_session: AsyncSession = Depends(generate_async_session), 107 | ): 108 | """ 109 | Endpoint to retrieve a Player by its ID. 110 | 111 | Args: 112 | player_id (int): The ID of the Player to retrieve. 113 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 114 | 115 | Returns: 116 | PlayerModel: The Pydantic model representing the matching Player. 117 | 118 | Raises: 119 | HTTPException: Not found error if the Player with the specified ID does not 120 | exist. 121 | """ 122 | player = await player_service.retrieve_by_id_async(async_session, player_id) 123 | if not player: 124 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 125 | return player 126 | 127 | 128 | @api_router.get( 129 | "/players/squadnumber/{squad_number}", 130 | response_model=PlayerModel, 131 | status_code=status.HTTP_200_OK, 132 | summary="Retrieves a Player by its Squad Number", 133 | tags=["Players"], 134 | ) 135 | async def get_by_squad_number_async( 136 | squad_number: int = Path(..., title="The Squad Number of the Player"), 137 | async_session: AsyncSession = Depends(generate_async_session), 138 | ): 139 | """ 140 | Endpoint to retrieve a Player by its Squad Number. 141 | 142 | Args: 143 | squad_number (int): The Squad Number of the Player to retrieve. 144 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 145 | 146 | Returns: 147 | PlayerModel: The Pydantic model representing the matching Player. 148 | 149 | Raises: 150 | HTTPException: HTTP 404 Not Found error if the Player with the specified 151 | Squad Number does not exist. 152 | """ 153 | player = await player_service.retrieve_by_squad_number_async( 154 | async_session, squad_number 155 | ) 156 | if not player: 157 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 158 | return player 159 | 160 | 161 | # PUT -------------------------------------------------------------------------- 162 | 163 | 164 | @api_router.put( 165 | "/players/{player_id}", 166 | status_code=status.HTTP_204_NO_CONTENT, 167 | summary="Updates an existing Player", 168 | tags=["Players"], 169 | ) 170 | async def put_async( 171 | player_id: int = Path(..., title="The ID of the Player"), 172 | player_model: PlayerModel = Body(...), 173 | async_session: AsyncSession = Depends(generate_async_session), 174 | ): 175 | """ 176 | Endpoint to entirely update an existing Player. 177 | 178 | Args: 179 | player_id (int): The ID of the Player to update. 180 | player_model (PlayerModel): The Pydantic model representing the Player to 181 | update. 182 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 183 | 184 | Raises: 185 | HTTPException: HTTP 404 Not Found error if the Player with the specified ID 186 | does not exist. 187 | """ 188 | player = await player_service.retrieve_by_id_async(async_session, player_id) 189 | if not player: 190 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 191 | await player_service.update_async(async_session, player_model) 192 | await simple_memory_cache.clear(CACHE_KEY) 193 | 194 | 195 | # DELETE ----------------------------------------------------------------------- 196 | 197 | 198 | @api_router.delete( 199 | "/players/{player_id}", 200 | status_code=status.HTTP_204_NO_CONTENT, 201 | summary="Deletes an existing Player", 202 | tags=["Players"], 203 | ) 204 | async def delete_async( 205 | player_id: int = Path(..., title="The ID of the Player"), 206 | async_session: AsyncSession = Depends(generate_async_session), 207 | ): 208 | """ 209 | Endpoint to delete an existing Player. 210 | 211 | Args: 212 | player_id (int): The ID of the Player to delete. 213 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 214 | 215 | Raises: 216 | HTTPException: HTTP 404 Not Found error if the Player with the specified ID 217 | does not exist. 218 | """ 219 | player = await player_service.retrieve_by_id_async(async_session, player_id) 220 | if not player: 221 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 222 | await player_service.delete_async(async_session, player_id) 223 | await simple_memory_cache.clear(CACHE_KEY) 224 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.13.3 2 | -------------------------------------------------------------------------------- /schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/schemas/__init__.py -------------------------------------------------------------------------------- /schemas/player_schema.py: -------------------------------------------------------------------------------- 1 | """ 2 | SQLAlchemy ORM model for the Player database table. 3 | 4 | Defines the schema and columns corresponding to football player attributes. 5 | 6 | Used for async database CRUD operations in the application. 7 | """ 8 | 9 | from sqlalchemy import Column, String, Integer, Boolean 10 | from databases.player_database import Base 11 | 12 | 13 | class Player(Base): 14 | """ 15 | SQLAlchemy schema describing a database table of football players. 16 | 17 | Attributes: 18 | id (Integer): The primary key for the player record. 19 | first_name (String): The first name of the player (not nullable). 20 | middle_name (String): The middle name of the player. 21 | last_name (String): The last name of the player (not nullable). 22 | date_of_birth (String): The date of birth of the player. 23 | squad_number (Integer): The squad number of the player (not nullable, unique). 24 | position (String): The playing position of the player (not nullable). 25 | abbr_position (String): The abbreviated form of the player's position. 26 | team (String): The team to which the player belongs. 27 | league (String): The league where the team plays. 28 | starting11 (Boolean): Indicates if the player is in the starting 11. 29 | """ 30 | 31 | __tablename__ = "players" 32 | 33 | id = Column(Integer, primary_key=True) 34 | first_name = Column(String, name="firstName", nullable=False) 35 | middle_name = Column(String, name="middleName") 36 | last_name = Column(String, name="lastName", nullable=False) 37 | date_of_birth = Column(String, name="dateOfBirth") 38 | squad_number = Column(Integer, name="squadNumber", unique=True, nullable=False) 39 | position = Column(String, nullable=False) 40 | abbr_position = Column(String, name="abbrPosition") 41 | team = Column(String) 42 | league = Column(String) 43 | starting11 = Column(Boolean) 44 | -------------------------------------------------------------------------------- /scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | IMAGE_STORAGE_PATH="/app/hold/players-sqlite3.db" 5 | VOLUME_STORAGE_PATH="/storage/players-sqlite3.db" 6 | 7 | echo "✔ Starting container..." 8 | 9 | if [ ! -f "$VOLUME_STORAGE_PATH" ]; then 10 | echo "⚠️ No existing database file found in volume." 11 | if [ -f "$IMAGE_STORAGE_PATH" ]; then 12 | echo "Copying database file to writable volume..." 13 | cp "$IMAGE_STORAGE_PATH" "$VOLUME_STORAGE_PATH" 14 | echo "✔ Database initialized at $VOLUME_STORAGE_PATH" 15 | else 16 | echo "⚠️ Database file missing at $IMAGE_STORAGE_PATH" 17 | exit 1 18 | fi 19 | else 20 | echo "✔ Existing database file found. Skipping seed copy." 21 | fi 22 | 23 | echo "✔ Ready!" 24 | echo "🚀 Launching app..." 25 | exec "$@" 26 | -------------------------------------------------------------------------------- /scripts/healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Minimal curl-based health check with timeout and error reporting 5 | curl --fail --silent --show-error --connect-timeout 1 --max-time 2 http://localhost:9000/health 6 | -------------------------------------------------------------------------------- /services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/services/__init__.py -------------------------------------------------------------------------------- /services/player_service.py: -------------------------------------------------------------------------------- 1 | """ 2 | Async CRUD operations for Player entities using SQLAlchemy ORM. 3 | 4 | Functions: 5 | - create_async : Add a new Player to the database. 6 | - retrieve_all_async : Fetch all Player records. 7 | - retrieve_by_id_async : Fetch a Player by its ID. 8 | - retrieve_by_squad_number_async : Fetch a Player by its Squad Number. 9 | - update_async : Fully update an existing Player. 10 | - delete_async : Remove a Player from the database. 11 | 12 | Handles SQLAlchemy exceptions with transaction rollback and logs errors. 13 | """ 14 | 15 | from sqlalchemy import select 16 | from sqlalchemy.ext.asyncio import AsyncSession 17 | from sqlalchemy.exc import SQLAlchemyError 18 | from models.player_model import PlayerModel 19 | from schemas.player_schema import Player 20 | 21 | # Create ----------------------------------------------------------------------- 22 | 23 | 24 | async def create_async(async_session: AsyncSession, player_model: PlayerModel): 25 | """ 26 | Creates a new Player in the database. 27 | 28 | Args: 29 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 30 | player_model (PlayerModel): The Pydantic model representing the Player to 31 | create. 32 | 33 | Returns: 34 | True if the Player was created successfully, False otherwise. 35 | """ 36 | # https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump 37 | player = Player(**player_model.model_dump()) 38 | async_session.add(player) 39 | try: 40 | await async_session.commit() 41 | return True 42 | except SQLAlchemyError as error: 43 | print(f"Error trying to create the Player: {error}") 44 | await async_session.rollback() 45 | return False 46 | 47 | 48 | # Retrieve --------------------------------------------------------------------- 49 | 50 | 51 | async def retrieve_all_async(async_session: AsyncSession): 52 | """ 53 | Retrieves all the players from the database. 54 | 55 | Args: 56 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 57 | 58 | Returns: 59 | A collection with all the players. 60 | """ 61 | # https://docs.sqlalchemy.org/en/20/changelog/migration_20.html#migration-20-query-usage 62 | statement = select(Player) 63 | result = await async_session.execute(statement) 64 | players = result.scalars().all() 65 | return players 66 | 67 | 68 | async def retrieve_by_id_async(async_session: AsyncSession, player_id: int): 69 | """ 70 | Retrieves a Player by its ID from the database. 71 | 72 | Args: 73 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 74 | player_id (int): The ID of the Player to retrieve. 75 | 76 | Returns: 77 | The Player matching the provided ID, or None if not found. 78 | """ 79 | player = await async_session.get(Player, player_id) 80 | return player 81 | 82 | 83 | async def retrieve_by_squad_number_async( 84 | async_session: AsyncSession, squad_number: int 85 | ): 86 | """ 87 | Retrieves a Player by its Squad Number from the database. 88 | 89 | Args: 90 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 91 | squad_number (int): The Squad Number of the Player to retrieve. 92 | 93 | Returns: 94 | The Player matching the provided Squad Number, or None if not found. 95 | """ 96 | statement = select(Player).where(Player.squad_number == squad_number) 97 | result = await async_session.execute(statement) 98 | player = result.scalars().first() 99 | return player 100 | 101 | 102 | # Update ----------------------------------------------------------------------- 103 | 104 | 105 | async def update_async(async_session: AsyncSession, player_model: PlayerModel): 106 | """ 107 | Updates (entirely) an existing Player in the database. 108 | 109 | Args: 110 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 111 | player_model (PlayerModel): The Pydantic model representing the Player to 112 | update. 113 | 114 | Returns: 115 | True if the Player was updated successfully, False otherwise. 116 | """ 117 | player_id = player_model.id # Extract ID from player_model 118 | player = await async_session.get(Player, player_id) 119 | player.first_name = player_model.first_name 120 | player.middle_name = player_model.middle_name 121 | player.last_name = player_model.last_name 122 | player.date_of_birth = player_model.date_of_birth 123 | player.squad_number = player_model.squad_number 124 | player.position = player_model.position 125 | player.abbr_position = player_model.abbr_position 126 | player.team = player_model.team 127 | player.league = player_model.league 128 | player.starting11 = player_model.starting11 129 | try: 130 | await async_session.commit() 131 | return True 132 | except SQLAlchemyError as error: 133 | print(f"Error trying to update the Player: {error}") 134 | await async_session.rollback() 135 | return False 136 | 137 | 138 | # Delete ----------------------------------------------------------------------- 139 | 140 | 141 | async def delete_async(async_session: AsyncSession, player_id: int): 142 | """ 143 | Deletes an existing Player from the database. 144 | 145 | Args: 146 | async_session (AsyncSession): The async version of a SQLAlchemy ORM session. 147 | player_id (int): The ID of the Player to delete. 148 | 149 | Returns: 150 | True if the Player was deleted successfully, False otherwise. 151 | """ 152 | player = await async_session.get(Player, player_id) 153 | await async_session.delete(player) 154 | try: 155 | await async_session.commit() 156 | return True 157 | except SQLAlchemyError as error: 158 | print(f"Error trying to delete the Player: {error}") 159 | await async_session.rollback() 160 | return False 161 | -------------------------------------------------------------------------------- /storage/players-sqlite3.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/storage/players-sqlite3.db -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nanotaboada/python-samples-fastapi-restful/8d0e44c40e6168400b6148370dbc31fb179ff835/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | import pytest 3 | from fastapi.testclient import TestClient 4 | from main import app 5 | 6 | # Suppress the DeprecationWarning from httpx 7 | warnings.filterwarnings("ignore", category=DeprecationWarning) 8 | 9 | 10 | @pytest.fixture(scope="function") 11 | def client(): 12 | """ 13 | Creates a test client for the FastAPI app. 14 | 15 | This fixture provides a fresh instance of TestClient for each test function, 16 | ensuring test isolation and a clean request context. 17 | 18 | Yields: 19 | TestClient: A client instance for sending HTTP requests to the FastAPI app. 20 | """ 21 | with TestClient(app) as test_client: 22 | yield test_client 23 | -------------------------------------------------------------------------------- /tests/player_stub.py: -------------------------------------------------------------------------------- 1 | class Player: 2 | """ 3 | Test stub representing a Player. 4 | """ 5 | 6 | def __init__( 7 | self, 8 | id=None, 9 | first_name=None, 10 | middle_name=None, 11 | last_name=None, 12 | date_of_birth=None, 13 | squad_number=None, 14 | position=None, 15 | abbr_position=None, 16 | team=None, 17 | league=None, 18 | starting11=None, 19 | ): 20 | self.id = id 21 | self.first_name = first_name 22 | self.middle_name = middle_name 23 | self.last_name = last_name 24 | self.date_of_birth = date_of_birth 25 | self.squad_number = squad_number 26 | self.position = position 27 | self.abbr_position = abbr_position 28 | self.team = team 29 | self.league = league 30 | self.starting11 = starting11 31 | 32 | 33 | def existing_player(): 34 | """ 35 | Creates a test stub for an existing Player. 36 | """ 37 | return Player( 38 | id=1, 39 | first_name="Damián", 40 | middle_name="Emiliano", 41 | last_name="Martínez", 42 | date_of_birth="1992-09-02T00:00:00.000Z", 43 | squad_number=23, 44 | position="Goalkeeper", 45 | abbr_position="GK", 46 | team="Aston Villa FC", 47 | league="Premier League", 48 | starting11=1, 49 | ) 50 | 51 | 52 | def nonexistent_player(): 53 | """ 54 | Creates a test stub for a nonexistent (new) Player. 55 | """ 56 | return Player( 57 | id=12, 58 | first_name="Leandro", 59 | middle_name="Daniel", 60 | last_name="Paredes", 61 | date_of_birth="1994-06-29T00:00:00.000Z", 62 | squad_number=5, 63 | position="Defensive Midfield", 64 | abbr_position="DM", 65 | team="AS Roma", 66 | league="Serie A", 67 | starting11=0, 68 | ) 69 | 70 | 71 | def unknown_player(): 72 | """ 73 | Creates a test stub for an unknown Player. 74 | """ 75 | return Player( 76 | id=999, 77 | first_name="John", 78 | last_name="Doe", 79 | squad_number="999", 80 | position="Lipsum", 81 | ) 82 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test suite for the /players/ API endpoints. 3 | 4 | Covers: 5 | - GET /health/ 6 | - GET /players/ 7 | - GET /players/{player_id} 8 | - GET /players/squadnumber/{squad_number} 9 | - POST /players/ 10 | - PUT /players/{player_id} 11 | - DELETE /players/{player_id} 12 | 13 | Validates: 14 | - Status codes, response bodies, headers (e.g., X-Cache) 15 | - Handling of existing, nonexistent, and malformed requests 16 | - Conflict and edge case behaviors 17 | """ 18 | 19 | import json 20 | from tests.player_stub import existing_player, nonexistent_player, unknown_player 21 | 22 | PATH = "/players/" 23 | 24 | # GET /health/ ----------------------------------------------------------------- 25 | 26 | 27 | def test_given_get_when_request_path_is_health_then_response_status_code_is_200( 28 | client, 29 | ): 30 | """ 31 | Given GET /health/ 32 | when request 33 | then response Status Code is 200 (OK) 34 | """ 35 | # Act 36 | response = client.get("/health/") 37 | # Assert 38 | assert response.status_code == 200 39 | assert response.json() == {"status": "ok"} 40 | 41 | 42 | # GET /players/ ---------------------------------------------------------------- 43 | 44 | 45 | def test_given_get_when_request_is_initial_then_response_header_x_cache_is_miss( 46 | client, 47 | ): 48 | """ 49 | Given GET /players/ 50 | when request is initial 51 | then response Header X-Cache value is MISS 52 | """ 53 | # Act 54 | response = client.get(PATH) 55 | 56 | # Assert 57 | assert "X-Cache" in response.headers 58 | assert response.headers.get("X-Cache") == "MISS" 59 | 60 | 61 | def test_given_get_when_request_is_subsequent_then_response_header_x_cache_is_hit( 62 | client, 63 | ): 64 | """ 65 | Given GET /players/ 66 | when request is subsequent 67 | then response Header X-Cache is HIT 68 | """ 69 | # Act 70 | client.get(PATH) # initial 71 | response = client.get(PATH) # subsequent (cached) 72 | 73 | # Assert 74 | assert "X-Cache" in response.headers 75 | assert response.headers.get("X-Cache") == "HIT" 76 | 77 | 78 | def test_given_get_when_request_path_has_no_id_then_response_status_code_is_200( 79 | client, 80 | ): 81 | """ 82 | Given GET /players/ 83 | when request path has no ID 84 | then response Status Code is 200 (OK) 85 | """ 86 | # Act 87 | response = client.get(PATH) 88 | # Assert 89 | assert response.status_code == 200 90 | 91 | 92 | def test_given_get_when_request_path_has_no_id_then_response_body_is_list_of_players( 93 | client, 94 | ): 95 | """ 96 | Given GET /players/ 97 | when request path has no ID 98 | then response Body is list of players 99 | """ 100 | # Act 101 | response = client.get(PATH) 102 | # Assert 103 | players = response.json() 104 | player_id = 0 105 | for player in players: 106 | player_id += 1 107 | assert player["id"] == player_id 108 | 109 | 110 | # GET /players/{player_id} ----------------------------------------------------- 111 | 112 | 113 | def test_given_get_when_request_path_is_nonexistent_id_then_response_status_code_is_404( 114 | client, 115 | ): 116 | """ 117 | Given GET /players/{player_id} 118 | when request path is nonexistent ID 119 | then response Status Code is 404 (Not Found) 120 | """ 121 | # Arrange 122 | player_id = nonexistent_player().id 123 | # Act 124 | response = client.get(PATH + str(player_id)) 125 | # Assert 126 | assert response.status_code == 404 127 | 128 | 129 | def test_given_get_when_request_path_is_existing_id_then_response_status_code_is_200( 130 | client, 131 | ): 132 | """ 133 | Given GET /players/{player_id} 134 | when request path is existing ID 135 | then response Status Code is 200 (OK) 136 | """ 137 | # Arrange 138 | player_id = existing_player().id 139 | # Act 140 | response = client.get(PATH + str(player_id)) 141 | # Assert 142 | assert response.status_code == 200 143 | 144 | 145 | def test_given_get_when_request_path_is_existing_id_then_response_is_matching_player( 146 | client, 147 | ): 148 | """ 149 | Given GET /players/{player_id} 150 | when request path is existing ID 151 | then response is matching Player 152 | """ 153 | # Arrange 154 | player_id = existing_player().id 155 | # Act 156 | response = client.get(PATH + str(player_id)) 157 | # Assert 158 | player = response.json() 159 | assert player["id"] == player_id 160 | 161 | 162 | # GET /players/squadnumber/{squad_number} -------------------------------------- 163 | 164 | 165 | def test_given_get_when_request_path_is_nonexistent_squad_number_then_response_status_code_is_404( 166 | client, 167 | ): 168 | """ 169 | Given GET /players/squadnumber/{squad_number} 170 | when request path is nonexistent Squad Number 171 | then response Status Code is 404 (Not Found) 172 | """ 173 | # Arrange 174 | squad_number = nonexistent_player().squad_number 175 | # Act 176 | response = client.get(PATH + "squadnumber" + "/" + str(squad_number)) 177 | # Assert 178 | assert response.status_code == 404 179 | 180 | 181 | def test_given_get_when_request_path_is_existing_squad_number_then_response_status_code_is_200( 182 | client, 183 | ): 184 | """ 185 | Given GET /players/squadnumber/{squad_number} 186 | when request path is existing Squad Number 187 | then response Status Code is 200 (OK) 188 | """ 189 | # Arrange 190 | squad_number = existing_player().squad_number 191 | # Act 192 | response = client.get(PATH + "squadnumber" + "/" + str(squad_number)) 193 | # Assert 194 | assert response.status_code == 200 195 | 196 | 197 | def test_given_get_when_request_path_is_existing_squad_number_then_response_is_matching_player( 198 | client, 199 | ): 200 | """ 201 | Given GET /players/squadnumber/{squad_number} 202 | when request path is existing Squad Number 203 | then response is matching Player 204 | """ 205 | # Arrange 206 | squad_number = existing_player().squad_number 207 | # Act 208 | response = client.get(PATH + "squadnumber" + "/" + str(squad_number)) 209 | # Assert 210 | player = response.json() 211 | assert player["squadNumber"] == squad_number 212 | 213 | 214 | # POST /players/ --------------------------------------------------------------- 215 | 216 | 217 | def test_given_post_when_request_body_is_empty_then_response_status_code_is_422( 218 | client, 219 | ): 220 | """ 221 | Given POST /players/ 222 | when request body is empty 223 | then response Status Code is 422 (Unprocessable Entity) 224 | """ 225 | # Arrange 226 | body = {} 227 | # Act 228 | response = client.post(PATH, data=body) 229 | # Assert 230 | assert response.status_code == 422 231 | 232 | 233 | def test_given_post_when_request_body_is_existing_player_then_response_status_code_is_409( 234 | client, 235 | ): 236 | """ 237 | Given POST /players/ 238 | when request body is existing Player 239 | then response Status Code is 409 (Conflict) 240 | """ 241 | # Arrange 242 | player = existing_player() 243 | body = json.dumps(player.__dict__) 244 | # Act 245 | response = client.post(PATH, data=body) 246 | # Assert 247 | assert response.status_code == 409 248 | 249 | 250 | def test_given_post_when_request_body_is_nonexistent_player_then_response_status_code_is_201( 251 | client, 252 | ): 253 | """ 254 | Given POST /players/ 255 | when request body is nonexistent Player 256 | then response Status Code is 201 (Created) 257 | """ 258 | # Arrange 259 | player = nonexistent_player() 260 | body = json.dumps(player.__dict__) 261 | # Act 262 | response = client.post(PATH, data=body) 263 | # Assert 264 | assert response.status_code == 201 265 | 266 | 267 | # PUT /players/{player_id} ----------------------------------------------------- 268 | 269 | 270 | def test_given_put_when_request_body_is_empty_then_response_status_code_is_422( 271 | client, 272 | ): 273 | """ 274 | Given PUT /players/{player_id} 275 | when request body is empty 276 | then response Status Code is 422 (Unprocessable Entity) 277 | """ 278 | # Arrange 279 | player_id = existing_player().id 280 | body = {} 281 | # Act 282 | response = client.put(PATH + str(player_id), data=body) 283 | # Assert 284 | assert response.status_code == 422 285 | 286 | 287 | def test_given_put_when_request_path_is_unknown_id_then_response_status_code_is_404( 288 | client, 289 | ): 290 | """ 291 | Given PUT /players/{player_id} 292 | when request path is unknown ID 293 | then response Status Code is 404 (Not Found) 294 | """ 295 | # Arrange 296 | player_id = unknown_player().id 297 | player = unknown_player() 298 | body = json.dumps(player.__dict__) 299 | # Act 300 | response = client.put(PATH + str(player_id), data=body) 301 | # Assert 302 | assert response.status_code == 404 303 | 304 | 305 | def test_given_put_when_request_path_is_existing_id_then_response_status_code_is_204( 306 | client, 307 | ): 308 | """ 309 | Given PUT /players/{player_id} 310 | when request path is existing ID 311 | then response Status Code is 204 (No Content) 312 | """ 313 | # Arrange 314 | player_id = existing_player().id 315 | player = existing_player() 316 | player.first_name = "Emiliano" 317 | player.middle_name = "" 318 | body = json.dumps(player.__dict__) 319 | # Act 320 | response = client.put(PATH + str(player_id), data=body) 321 | # Assert 322 | assert response.status_code == 204 323 | 324 | 325 | # DELETE /players/{player_id} -------------------------------------------------- 326 | 327 | 328 | def test_given_delete_when_request_path_is_unknown_id_then_response_status_code_is_404( 329 | client, 330 | ): 331 | """ 332 | Given DELETE /players/{player_id} 333 | when request path is unknown ID 334 | then response Status Code is 404 (Not Found) 335 | """ 336 | # Arrange 337 | player_id = unknown_player().id 338 | # Act 339 | response = client.delete(PATH + str(player_id)) 340 | # Assert 341 | assert response.status_code == 404 342 | 343 | 344 | def test_given_delete_when_request_path_is_existing_id_then_response_status_code_is_204( 345 | client, 346 | ): 347 | """ 348 | Given DELETE /players/{player_id} 349 | when request path is existing ID 350 | then response Status Code is 204 (No Content) 351 | """ 352 | # Arrange 353 | player_id = 12 # nonexistent_player() previously created 354 | # Act 355 | response = client.delete(PATH + str(player_id)) 356 | # Assert 357 | assert response.status_code == 204 358 | --------------------------------------------------------------------------------