├── .dockerignore ├── .fastapi-mvc.yml ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── integration.yml │ ├── main.yml │ ├── nix.yml │ └── update-flake.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── TAG ├── Vagrantfile ├── build ├── dev-env.sh ├── image.sh └── install.sh ├── charts └── example │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── configmap.yml │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml │ └── values.yaml ├── default.nix ├── docs ├── _static │ └── logo.png ├── api.rst ├── changelog.rst ├── conf.py ├── deployment.rst ├── index.rst ├── install.rst ├── license.rst ├── nix.rst └── usage.rst ├── editable.nix ├── example ├── __init__.py ├── __main__.py ├── app │ ├── __init__.py │ ├── asgi.py │ ├── controllers │ │ ├── __init__.py │ │ └── ready.py │ ├── exceptions │ │ ├── __init__.py │ │ └── http.py │ ├── models │ │ └── __init__.py │ ├── router.py │ ├── utils │ │ ├── __init__.py │ │ ├── aiohttp_client.py │ │ └── redis.py │ └── views │ │ ├── __init__.py │ │ ├── error.py │ │ └── ready.py ├── cli │ ├── __init__.py │ ├── cli.py │ ├── serve.py │ └── utils.py ├── config │ ├── __init__.py │ ├── application.py │ ├── gunicorn.py │ └── redis.py ├── py.typed ├── version.py └── wsgi.py ├── flake.lock ├── flake.nix ├── image.nix ├── manifests ├── all-redis-operator-resources.yaml └── persistent-storage-no-pvc-deletion.yaml ├── overlay.nix ├── poetry.lock ├── pyproject.toml ├── shell.nix └── tests ├── __init__.py ├── integration ├── __init__.py ├── app │ ├── __init__.py │ └── controllers │ │ ├── __init__.py │ │ └── test_ready.py └── conftest.py └── unit ├── __init__.py ├── app ├── __init__.py ├── conftest.py ├── controllers │ ├── __init__.py │ └── test_ready.py ├── exceptions │ ├── __init__.py │ └── test_http.py ├── models │ └── __init__.py ├── test_asgi.py ├── utils │ ├── __init__.py │ ├── test_aiohttp_client.py │ └── test_redis.py └── views │ ├── __init__.py │ ├── test_error.py │ └── test_ready.py ├── cli ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_serve.py └── test_utils.py ├── conftest.py └── test_wsgi.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.py[cod] 3 | *$py.class 4 | *.egg-info 5 | .installed.cfg 6 | *.egg 7 | htmlcov 8 | .tox/ 9 | .pytest_cache 10 | .mypy_cache 11 | .coverage 12 | .coverage.* 13 | .cache 14 | nosetests.xml 15 | coverage.xml 16 | *,cover 17 | .hypothesis 18 | xunit-*.xml 19 | .idea 20 | venv 21 | .venv 22 | .vagrant 23 | .direnv 24 | site 25 | .git 26 | .github 27 | result -------------------------------------------------------------------------------- /.fastapi-mvc.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | _commit: 069ddc6 3 | _src_path: . 4 | aiohttp: true 5 | author: Radosław Szamszur 6 | chart_name: example 7 | container_image_name: example 8 | copyright_date: 2022 9 | email: github@rsd.sh 10 | fastapi_mvc_version: Generated from CI 11 | github_actions: true 12 | helm: true 13 | license: MIT 14 | nix: true 15 | package_name: example 16 | project_description: This project was generated with fastapi-mvc. 17 | project_name: example 18 | redis: true 19 | repo_url: https://github.com/fastapi-mvc/example 20 | script_name: example 21 | version: 0.1.0 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | 3 | on: 4 | # This trigger is required by fastapi-mvc automation to dispatch this concrete workflow 5 | # from fastapi-mvc 'CI workflow' (https://github.com/fastapi-mvc/cookiecutter/actions/workflows/main.yml), 6 | # and await its result. NOTE! This is not included in the template. 7 | workflow_dispatch: 8 | inputs: 9 | distinct_id: 10 | required: true 11 | description: "Input required by codex-/return-dispatch@v1" 12 | push: 13 | branches: 14 | - master 15 | 16 | env: 17 | POETRY_HOME: /opt/poetry 18 | POETRY_CONFIG_DIR: /opt/poetry 19 | POETRY_CACHE_DIR: /opt/poetry/cache 20 | POETRY_VIRTUALENVS_PATH: /opt/poetry/store 21 | 22 | jobs: 23 | meta: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | # This echo is required by codex-/return-dispatch@v1 in order to identify dispatched workflow. 28 | # NOTE! This is not included in the template. 29 | - name: echo distinct ID ${{ github.event.inputs.distinct_id }} 30 | run: echo ${{ github.event.inputs.distinct_id }} 31 | build-docs: 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - uses: actions/checkout@v3 36 | - name: Set up Python 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: 3.9 40 | - name: Load Poetry cache 41 | id: poetry-cache 42 | uses: actions/cache@v3 43 | with: 44 | path: ${{ env.POETRY_HOME }} 45 | key: ${{ runner.os }}-3.9-${{ hashFiles('./pyproject.toml') }} 46 | - name: Build documentation 47 | run: make docs 48 | - name: Archive build artifacts 49 | uses: actions/upload-artifact@v3 50 | with: 51 | name: ${{ format('docs-{0}', github.sha) }} 52 | path: site 53 | retention-days: 60 54 | - name: Deploy to GitHub Pages 55 | uses: crazy-max/ghaction-github-pages@v3.1.0 56 | with: 57 | target_branch: gh-pages 58 | build_dir: site 59 | commit_message: "Deploy to GitHubPages" 60 | jekyll: false 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: K8s integration 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | # This job checks if an identical workflow is being triggered by different 8 | # event and skips it. For instance there is no need to run the same pipeline 9 | # twice for pull_request and push for identical commit sha. 10 | pre_job: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 14 | steps: 15 | - id: skip_check 16 | uses: fkirc/skip-duplicate-actions@v5.3.0 17 | with: 18 | skip_after_successful_duplicate: 'true' 19 | concurrent_skipping: same_content 20 | do_not_skip: '["pull_request", "workflow_dispatch", "schedule"]' 21 | test: 22 | needs: pre_job 23 | if: ${{ needs.pre_job.outputs.should_skip != 'true' }} 24 | runs-on: macos-12 25 | 26 | steps: 27 | - uses: actions/checkout@v3 28 | - name: Run vagrant up 29 | run: vagrant up 30 | - name: Bootstrap minukube cluster and Redis operator 31 | run: vagrant ssh -c "cd /syncd && make dev-env" 32 | - name: Test exposed example application 33 | run: vagrant ssh -c 'curl "http://example.$(minikube ip).nip.io/api/ready"' -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # This trigger is required by fastapi-mvc automation to dispatch this concrete workflow 5 | # from fastapi-mvc 'CI workflow' (https://github.com/fastapi-mvc/cookiecutter/actions/workflows/main.yml), 6 | # and await its result. NOTE! This is not included in the template. 7 | workflow_dispatch: 8 | inputs: 9 | distinct_id: 10 | required: true 11 | description: "Input required by codex-/return-dispatch@v1" 12 | push: 13 | branches: 14 | - master 15 | pull_request: 16 | branches: 17 | - master 18 | 19 | env: 20 | POETRY_HOME: /opt/poetry 21 | POETRY_CONFIG_DIR: /opt/poetry 22 | POETRY_CACHE_DIR: /opt/poetry/cache 23 | POETRY_VIRTUALENVS_PATH: /opt/poetry/store 24 | DEFAULT_PYTHON: '3.10' 25 | 26 | jobs: 27 | meta: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | # This echo is required by codex-/return-dispatch@v1 in order to identify dispatched workflow. 32 | # NOTE! This is not included in the template. 33 | - name: echo distinct ID ${{ github.event.inputs.distinct_id }} 34 | run: echo ${{ github.event.inputs.distinct_id }} 35 | # This job checks if an identical workflow is being triggered by different 36 | # event and skips it. For instance there is no need to run the same pipeline 37 | # twice for pull_request and push for identical commit sha. 38 | pre_job: 39 | runs-on: ubuntu-latest 40 | outputs: 41 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 42 | steps: 43 | - id: skip_check 44 | uses: fkirc/skip-duplicate-actions@v5.3.0 45 | with: 46 | skip_after_successful_duplicate: 'true' 47 | concurrent_skipping: same_content 48 | do_not_skip: '["pull_request", "workflow_dispatch", "schedule"]' 49 | install: 50 | needs: pre_job 51 | if: ${{ needs.pre_job.outputs.should_skip != 'true' }} 52 | runs-on: ubuntu-latest 53 | strategy: 54 | matrix: 55 | python-version: [ '3.8', '3.9', '3.10', '3.11' ] 56 | 57 | steps: 58 | - uses: actions/checkout@v3 59 | - name: Set up Python ${{ matrix.python-version }} 60 | uses: actions/setup-python@v4 61 | with: 62 | python-version: ${{ matrix.python-version }} 63 | - name: Init Poetry cache 64 | id: cached-poetry 65 | uses: actions/cache@v3 66 | with: 67 | path: ${{ env.POETRY_HOME }} 68 | key: ${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('./pyproject.toml') }}-${{ hashFiles('./poetry.lock') }} 69 | - name: Install package 70 | run: make install 71 | if: steps.cached-poetry.outputs.cache-hit != 'true' 72 | build: 73 | needs: install 74 | runs-on: ubuntu-latest 75 | 76 | steps: 77 | - uses: actions/checkout@v3 78 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 79 | uses: actions/setup-python@v4 80 | with: 81 | python-version: ${{ env.DEFAULT_PYTHON }} 82 | - name: Load Poetry cache 83 | uses: actions/cache@v3 84 | with: 85 | path: ${{ env.POETRY_HOME }} 86 | key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-${{ hashFiles('./pyproject.toml') }}-${{ hashFiles('./poetry.lock') }} 87 | - name: Build wheel 88 | run: make build 89 | - name: Archive build artifacts 90 | uses: actions/upload-artifact@v3 91 | with: 92 | name: ${{ format('fastapi_mvc-{0}', github.sha) }} 93 | path: dist 94 | retention-days: 60 95 | metrics: 96 | needs: install 97 | runs-on: ubuntu-latest 98 | 99 | steps: 100 | - uses: actions/checkout@v3 101 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 102 | uses: actions/setup-python@v4 103 | with: 104 | python-version: ${{ env.DEFAULT_PYTHON }} 105 | - name: Load Poetry cache 106 | uses: actions/cache@v3 107 | with: 108 | path: ${{ env.POETRY_HOME }} 109 | key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-${{ hashFiles('./pyproject.toml') }}-${{ hashFiles('./poetry.lock') }} 110 | - name: Run metrics checks 111 | run: make metrics 112 | unit-tests: 113 | needs: install 114 | runs-on: ubuntu-latest 115 | strategy: 116 | fail-fast: false 117 | matrix: 118 | python-version: [ '3.8', '3.9', '3.10', '3.11' ] 119 | 120 | steps: 121 | - uses: actions/checkout@v3 122 | - name: Set up Python ${{ matrix.python-version }} 123 | uses: actions/setup-python@v4 124 | with: 125 | python-version: ${{ matrix.python-version }} 126 | - name: Load Poetry cache 127 | uses: actions/cache@v3 128 | with: 129 | path: ${{ env.POETRY_HOME }} 130 | key: ${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('./pyproject.toml') }}-${{ hashFiles('./poetry.lock') }} 131 | - name: Run unit tests 132 | run: make unit-test 133 | integration-tests: 134 | needs: install 135 | runs-on: ubuntu-latest 136 | strategy: 137 | fail-fast: false 138 | matrix: 139 | python-version: [ '3.8', '3.9', '3.10', '3.11' ] 140 | 141 | steps: 142 | - uses: actions/checkout@v3 143 | - name: Set up Python ${{ matrix.python-version }} 144 | uses: actions/setup-python@v4 145 | with: 146 | python-version: ${{ matrix.python-version }} 147 | - name: Load Poetry cache 148 | uses: actions/cache@v3 149 | with: 150 | path: ${{ env.POETRY_HOME }} 151 | key: ${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('./pyproject.toml') }}-${{ hashFiles('./poetry.lock') }} 152 | - name: Run integration tests 153 | run: make integration-test 154 | coverage: 155 | needs: install 156 | runs-on: ubuntu-latest 157 | 158 | steps: 159 | - uses: actions/checkout@v3 160 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 161 | uses: actions/setup-python@v4 162 | with: 163 | python-version: ${{ env.DEFAULT_PYTHON }} 164 | - name: Load Poetry cache 165 | uses: actions/cache@v3 166 | with: 167 | path: ${{ env.POETRY_HOME }} 168 | key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-${{ hashFiles('./pyproject.toml') }}-${{ hashFiles('./poetry.lock') }} 169 | - name: Run coverage 170 | run: make coverage 171 | mypy: 172 | needs: install 173 | runs-on: ubuntu-latest 174 | 175 | steps: 176 | - uses: actions/checkout@v3 177 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 178 | uses: actions/setup-python@v4 179 | with: 180 | python-version: ${{ env.DEFAULT_PYTHON }} 181 | - name: Load Poetry cache 182 | uses: actions/cache@v3 183 | with: 184 | path: ${{ env.POETRY_HOME }} 185 | key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-${{ hashFiles('./pyproject.toml') }}-${{ hashFiles('./poetry.lock') }} 186 | - name: Run mypy checks 187 | run: make mypy -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | 2 | name: ❄️ Nix CI ❄️ 3 | 4 | on: 5 | # This trigger is required by fastapi-mvc automation to dispatch this concrete workflow 6 | # from fastapi-mvc 'CI workflow' (https://github.com/fastapi-mvc/cookiecutter/actions/workflows/main.yml), 7 | # and await its result. NOTE! This is not included in the template. 8 | workflow_dispatch: 9 | inputs: 10 | distinct_id: 11 | required: true 12 | description: "Input required by codex-/return-dispatch@v1" 13 | push: 14 | branches: 15 | - master 16 | pull_request: 17 | branches: 18 | - master 19 | 20 | env: 21 | NIX_CHANNEL: nixpkgs=channel:nixos-22.11 22 | NIX_INSTALL_URL: https://releases.nixos.org/nix/nix-2.13.3/install 23 | 24 | jobs: 25 | meta: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | # This echo is required by codex-/return-dispatch@v1 in order to identify dispatched workflow. 30 | # NOTE! This is not included in the template. 31 | - name: echo distinct ID ${{ github.event.inputs.distinct_id }} 32 | run: echo ${{ github.event.inputs.distinct_id }} 33 | # This job checks if an identical workflow is being triggered by different 34 | # event and skips it. For instance there is no need to run the same pipeline 35 | # twice for pull_request and push for identical commit sha. 36 | pre_job: 37 | runs-on: ubuntu-latest 38 | outputs: 39 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 40 | steps: 41 | - id: skip_check 42 | uses: fkirc/skip-duplicate-actions@v5.3.0 43 | with: 44 | skip_after_successful_duplicate: 'true' 45 | concurrent_skipping: same_content 46 | do_not_skip: '["pull_request", "workflow_dispatch", "schedule"]' 47 | nix-checks: 48 | needs: pre_job 49 | if: ${{ needs.pre_job.outputs.should_skip != 'true' }} 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - uses: cachix/install-nix-action@v22 54 | with: 55 | nix_path: ${{ env.NIX_CHANNEL }} 56 | install_url: ${{ env.NIX_INSTALL_URL }} 57 | # Remove bellow step if you do not want to use Cachix - Nix binary cache. 58 | # For OpenSource projects there is free 5GB of storage. 59 | # https://www.cachix.org 60 | - name: Setup Cachix ❄️ 61 | uses: cachix/cachix-action@v12 62 | with: 63 | name: fastapi-mvc 64 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 65 | - uses: actions/checkout@v3 66 | - name: Check format 67 | run: nix-shell -p nixpkgs-fmt --run 'nixpkgs-fmt --check .' 68 | - name: Run nix flake check 69 | run: nix flake check 70 | - name: Run metrics checks 71 | run: nix run .#metrics 72 | - name: Run mypy checks 73 | run: nix run .#mypy 74 | - name: Run tests 75 | run: nix run .#test 76 | nix-build: 77 | needs: pre_job 78 | if: ${{ needs.pre_job.outputs.should_skip != 'true' }} 79 | strategy: 80 | fail-fast: false 81 | matrix: 82 | python-version: [ '38', '39', '310' ] 83 | runs-on: ubuntu-latest 84 | 85 | steps: 86 | - uses: actions/checkout@v3 87 | - name: Install Nix ❄️ 88 | uses: cachix/install-nix-action@v22 89 | with: 90 | extra_nix_config: "system-features = nixos-test benchmark big-parallel kvm" 91 | nix_path: ${{ env.NIX_CHANNEL }} 92 | install_url: ${{ env.NIX_INSTALL_URL }} 93 | # Remove bellow step if you do not want to use Cachix - Nix binary cache. 94 | # For OpenSource projects there is free 5GB of storage. 95 | # https://www.cachix.org 96 | - name: Setup Cachix ❄️ 97 | uses: cachix/cachix-action@v12 98 | with: 99 | name: fastapi-mvc 100 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 101 | - name: Build example 102 | run: nix build .#example-py${{ matrix.python-version }} -------------------------------------------------------------------------------- /.github/workflows/update-flake.yml: -------------------------------------------------------------------------------- 1 | name: Update flake 2 | on: 3 | workflow_dispatch: 4 | # # runs weekly on Sunday at 00:00 5 | # schedule: 6 | # - cron: '0 0 * * 0' 7 | 8 | jobs: 9 | lockfile: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | - name: Install Nix 15 | uses: DeterminateSystems/nix-installer-action@v4 16 | - name: Update flake.lock 17 | uses: DeterminateSystems/update-flake-lock@v19 18 | with: 19 | pr-title: "Update flake.lock" 20 | # https://github.com/DeterminateSystems/update-flake-lock#with-a-personal-authentication-token 21 | token: ${{ secrets.API_TOKEN_GITHUB }} 22 | pr-labels: | 23 | dependencies 24 | pr-body: | 25 | Automated changes by the [update-flake-lock](https://github.com/DeterminateSystems/update-flake-lock) GitHub Action. 26 | ``` 27 | {{ env.GIT_COMMIT_MESSAGE }} 28 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | *.egg-info/ 7 | .installed.cfg 8 | *.egg 9 | 10 | # Unit test / coverage reports 11 | htmlcov/ 12 | .tox/ 13 | .pytest_cache/ 14 | .mypy_cache/ 15 | .coverage 16 | .coverage.* 17 | .cache 18 | nosetests.xml 19 | coverage.xml 20 | *,cover 21 | .hypothesis/ 22 | xunit-*.xml 23 | 24 | # PyCharm 25 | .idea/ 26 | 27 | # virtual 28 | venv/ 29 | .venv/ 30 | 31 | # vagrant 32 | .vagrant/ 33 | 34 | # direnv 35 | .direnv/ 36 | 37 | # Sphinx build dir 38 | site/ 39 | 40 | # Nix build result 41 | result -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This file documents changes to [example](https://github.com/fastapi-mvc/example). The release numbering uses [semantic versioning](http://semver.org). 4 | 5 | ## 0.1.0 6 | 7 | * Initial release 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This digest SHA points to python:3.9-slim-bullseye tag 2 | FROM python@sha256:a9cf2d58b33ba6f273e80d1f6272186d8930c062fa2a2abc65f35bdf4609a032 as builder 3 | LABEL maintainer="Radosław Szamszur, github@rsd.sh" 4 | 5 | # Configure environment variables 6 | ENV PYTHONUNBUFFERED=1 \ 7 | PYTHONHASHSEED=0 \ 8 | SOURCE_DATE_EPOCH=315532800 \ 9 | CFLAGS=-g0 \ 10 | PYTHONDONTWRITEBYTECODE=1 \ 11 | PIP_NO_CACHE_DIR=off \ 12 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 13 | PIP_DEFAULT_TIMEOUT=100 \ 14 | POETRY_HOME="/opt/poetry" \ 15 | POETRY_VIRTUALENVS_IN_PROJECT=true \ 16 | POETRY_NO_INTERACTION=1 \ 17 | POETRY_VERSION=1.3.2 \ 18 | POETRY_INSTALL_OPTS="--no-interaction --without dev --no-root" \ 19 | PYSETUP_PATH="/pysetup" \ 20 | VENV_PATH="/pysetup/.venv" 21 | 22 | ENV PATH="${POETRY_HOME}/bin:${VENV_PATH}/bin:${PATH}" 23 | 24 | # Configure Debian snapshot archive 25 | RUN echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20220124 bullseye main" > /etc/apt/sources.list && \ 26 | echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20220124 bullseye-security main" >> /etc/apt/sources.list && \ 27 | echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20220124 bullseye-updates main" >> /etc/apt/sources.list 28 | 29 | # Install build tools and dependencies 30 | RUN apt-get update && \ 31 | apt-get install --no-install-recommends -y curl build-essential 32 | 33 | # Install project without root package, then build and install from wheel. 34 | # This is needed because Poetry doesn't support installing root package without 35 | # editable mode: https://github.com/python-poetry/poetry/issues/1382 36 | # Otherwise venv with source code would need to be copied to final image. 37 | COPY . $PYSETUP_PATH 38 | WORKDIR $PYSETUP_PATH 39 | RUN make install && \ 40 | poetry build && \ 41 | $VENV_PATH/bin/pip install --no-deps dist/*.whl 42 | 43 | # Override virtualenv Python symlink to Python path in gcr.io/distroless/python3 image 44 | RUN ln -fns /usr/bin/python $VENV_PATH/bin/python 45 | 46 | 47 | # Use distroless Python3 image, locked to digest SHA in order to have deterministic Python version - 3.9.2. 48 | # For the time being, gcr.io/distroless/python3 doesn't have any tags to particular minor version. 49 | # This digest SHA points to python3:nonroot 50 | FROM gcr.io/distroless/python3@sha256:a66e582f67df92987039ad8827f0773f96020661c7ae6272e5ab80e2d3abc897 51 | LABEL maintainer="Radosław Szamszur, github@rsd.sh" 52 | 53 | ENV PYTHONDONTWRITEBYTECODE=1 \ 54 | PYTHONUNBUFFERED=1 \ 55 | VENV_PATH="/pysetup/.venv" 56 | 57 | COPY --from=builder $VENV_PATH $VENV_PATH 58 | 59 | ENV PATH="${VENV_PATH}/bin:${PATH}" 60 | 61 | USER nonroot 62 | 63 | EXPOSE 8000/tcp 64 | 65 | STOPSIGNAL SIGINT 66 | 67 | ENTRYPOINT ["example"] 68 | 69 | CMD ["serve", "--bind", "0.0.0.0:8000"] 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Radosław Szamszur 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_GOAL:=help 2 | 3 | .EXPORT_ALL_VARIABLES: 4 | 5 | ifndef VERBOSE 6 | .SILENT: 7 | endif 8 | 9 | # set default shell 10 | SHELL=/usr/bin/env bash -o pipefail -o errexit 11 | 12 | TAG ?= $(shell cat TAG) 13 | POETRY_HOME ?= ${HOME}/.local/share/pypoetry 14 | POETRY_BINARY ?= ${POETRY_HOME}/venv/bin/poetry 15 | POETRY_VERSION ?= 1.3.2 16 | 17 | help: ## Display this help 18 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 19 | 20 | .PHONY: show-version 21 | show-version: ## Display version 22 | echo -n "${TAG}" 23 | 24 | .PHONY: build 25 | build: ## Build example package 26 | echo "[build] Build example package." 27 | ${POETRY_BINARY} build 28 | 29 | .PHONY: install 30 | install: ## Install example with poetry 31 | @build/install.sh 32 | 33 | .PHONY: image 34 | image: ## Build example image 35 | @build/image.sh 36 | 37 | .PHONY: metrics 38 | metrics: install ## Run example metrics checks 39 | echo "[metrics] Run example PEP 8 checks." 40 | ${POETRY_BINARY} run flake8 --select=E,W,I --max-line-length 88 --import-order-style pep8 --statistics --count example 41 | echo "[metrics] Run example PEP 257 checks." 42 | ${POETRY_BINARY} run flake8 --select=D --ignore D301 --statistics --count example 43 | echo "[metrics] Run example pyflakes checks." 44 | ${POETRY_BINARY} run flake8 --select=F --statistics --count example 45 | echo "[metrics] Run example code complexity checks." 46 | ${POETRY_BINARY} run flake8 --select=C901 --statistics --count example 47 | echo "[metrics] Run example open TODO checks." 48 | ${POETRY_BINARY} run flake8 --select=T --statistics --count example tests 49 | echo "[metrics] Run example black checks." 50 | ${POETRY_BINARY} run black --check example 51 | 52 | .PHONY: unit-test 53 | unit-test: install ## Run example unit tests 54 | echo "[unit-test] Run example unit tests." 55 | ${POETRY_BINARY} run pytest tests/unit 56 | 57 | .PHONY: integration-test 58 | integration-test: install ## Run example integration tests 59 | echo "[unit-test] Run example integration tests." 60 | ${POETRY_BINARY} run pytest tests/integration 61 | 62 | .PHONY: coverage 63 | coverage: install ## Run example tests coverage 64 | echo "[coverage] Run example tests coverage." 65 | ${POETRY_BINARY} run pytest --cov=example --cov-fail-under=90 --cov-report=xml --cov-report=term-missing tests 66 | 67 | .PHONY: test 68 | test: unit-test integration-test ## Run example tests 69 | 70 | .PHONY: docs 71 | docs: install ## Build example documentation 72 | echo "[docs] Build example documentation." 73 | ${POETRY_BINARY} run sphinx-build docs site 74 | 75 | .PHONY: mypy 76 | mypy: install ## Run example mypy checks 77 | echo "[mypy] Run example mypy checks." 78 | ${POETRY_BINARY} run mypy example 79 | 80 | .PHONY: dev-env 81 | dev-env: image ## Start a local Kubernetes cluster using minikube and deploy application 82 | @build/dev-env.sh 83 | 84 | .PHONY: clean 85 | clean: ## Remove .cache directory and cached minikube 86 | minikube delete && rm -rf ~/.cache ~/.minikube 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # example 2 | [![CI](https://github.com/fastapi-mvc/example/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/fastapi-mvc/example/actions/workflows/main.yml) 3 | [![K8s integration](https://github.com/fastapi-mvc/example/actions/workflows/integration.yml/badge.svg)](https://github.com/fastapi-mvc/example/actions/workflows/integration.yml) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | ![GitHub](https://img.shields.io/badge/fastapi-v.0.98.0-blue) 6 | ![GitHub](https://img.shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20%7C%203.11-blue) 7 | ![GitHub](https://img.shields.io/badge/license-MIT-blue) 8 | 9 | --- 10 | 11 | ## This project was generated with [fastapi-mvc](https://github.com/fastapi-mvc/fastapi-mvc) 12 | 13 | ### Documentation 14 | 15 | 16 | You should have documentation deployed to your project GitHub pages via [Build Docs workflow](https://github.com/fastapi-mvc/example/actions/workflows/docs.yml) 17 | 18 | **NOTE!** You might need to enable GitHub pages for this project first. 19 | 20 | To build docs manually: 21 | ```shell 22 | make docs 23 | ``` 24 | 25 | Then open `./site/index.html` with any browser. 26 | 27 | ## License 28 | 29 | This project is licensed under the terms of the MIT license. 30 | -------------------------------------------------------------------------------- /TAG: -------------------------------------------------------------------------------- 1 | 0.1.0 -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | Vagrant.configure("2") do |config| 4 | config.vm.box = "debian/bullseye64" 5 | config.vm.box_version = "11.20220328.1" 6 | config.vm.provider "virtualbox" do |vb| 7 | vb.memory = "4096" 8 | vb.cpus = "2" 9 | 10 | # https://github.com/hashicorp/vagrant/issues/11777#issuecomment-661076612 11 | vb.customize ["modifyvm", :id, "--uart1", "0x3F8", "4"] 12 | vb.customize ["modifyvm", :id, "--uartmode1", "file", File::NULL] 13 | 14 | # NOTE: use host resolver to increase dl speeds for python 15 | vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] 16 | vb.customize ["modifyvm", :id, "--natdnsproxy1", "on"] 17 | end 18 | config.vm.provision "shell", inline: <<-SHELL 19 | echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20220328 bullseye main" > /etc/apt/sources.list 20 | echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20220328 bullseye-security main" >> /etc/apt/sources.list 21 | echo "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/20220328 bullseye-updates main" >> /etc/apt/sources.list 22 | apt-get update 23 | apt-get install --no-install-recommends -y curl make docker.io git golang python3 python3-pip python3-venv 24 | systemctl enable docker 25 | # NOTE: Fix resolving DNS in docker 26 | echo '{ "dns": ["192.168.0.1", "8.8.8.8"] }' > /etc/docker/daemon.json 27 | systemctl restart docker 28 | usermod -aG docker vagrant 29 | # Install kubectl 30 | curl -Lso /tmp/kubectl "https://storage.googleapis.com/kubernetes-release/release/v1.20.8/bin/linux/amd64/kubectl" 31 | install -m 755 /tmp/kubectl /usr/local/bin 32 | # Install minikube 33 | curl -Lso /tmp/minikube https://storage.googleapis.com/minikube/releases/v1.22.0/minikube-linux-amd64 34 | install -m 755 /tmp/minikube /usr/local/bin/ 35 | 36 | # NOTE: no need to statrt minikube just yet 37 | #sudo -u vagrant MINIKUBE_IN_STYLE=0 minikube start --driver=docker 2> /dev/null 38 | # Install Helm 39 | curl -s https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash 40 | 41 | SHELL 42 | # https://github.com/hashicorp/vagrant/issues/10002#issuecomment-419503397 43 | config.trigger.after :up do |t| 44 | t.info = "rsync auto" 45 | #t.run = {inline: "vagrant rsync-auto"} 46 | # If you want it running in the background switch these 47 | t.run = {inline: "bash -c 'vagrant rsync-auto &'"} 48 | end 49 | config.vm.synced_folder ".", "/syncd", type: "rsync" 50 | config.vm.boot_timeout = 600 51 | end -------------------------------------------------------------------------------- /build/dev-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -n "$DEBUG" ]; then 4 | set -x 5 | fi 6 | 7 | set -o errexit 8 | set -o nounset 9 | set -o pipefail 10 | 11 | DIR=$(cd $(dirname "${BASH_SOURCE}") && pwd -P) 12 | 13 | if ! command -v minikube &> /dev/null; then 14 | echo "minikube is not installed" 15 | exit 1 16 | fi 17 | 18 | if ! command -v kubectl &> /dev/null; then 19 | echo "kubectl is not installed" 20 | exit 1 21 | fi 22 | 23 | HELM_VERSION=$(helm version 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+') || true 24 | if [[ ${HELM_VERSION} < "v3.0.0" ]]; then 25 | echo "Please upgrade helm to v3.0.0 or higher" 26 | exit 1 27 | fi 28 | 29 | KUBE_CLIENT_VERSION=$(kubectl version --client --short | awk '{print $3}' | cut -d. -f2) || true 30 | if [[ ${KUBE_CLIENT_VERSION} -lt 16 ]]; then 31 | echo "Please update kubectl to 1.16 or higher" 32 | exit 1 33 | fi 34 | 35 | echo "[dev-env] starting minikube" 36 | MINIKUBE_IN_STYLE=0 minikube start --driver=docker 37 | 38 | echo "[dev-env] enabling ingress addon" 39 | minikube addons enable ingress 40 | 41 | echo "[dev-env] enabling ssl-passthrough for ingress controller" 42 | # Check deployment rollout status every 10 seconds (max 10 minutes) until complete. 43 | ATTEMPTS=0 44 | ROLLOUT_STATUS_CMD="kubectl rollout status deployment/ingress-nginx-controller -n ingress-nginx" 45 | until $ROLLOUT_STATUS_CMD || [ $ATTEMPTS -eq 60 ]; do 46 | ATTEMPTS=$((ATTEMPTS + 1)) 47 | sleep 10 48 | done 49 | 50 | INGRESS_HOST="$(minikube ip).nip.io" 51 | 52 | echo "[dev-env] pushing app image" 53 | minikube image load "example:${TAG}" 54 | 55 | echo "[dev-env] creating example namespace" 56 | kubectl create namespace example 57 | 58 | ATTEMPTS=0 59 | ROLLOUT_STATUS_CMD="kubectl get namespace example -n example" 60 | until $ROLLOUT_STATUS_CMD 2>/dev/null || [ $ATTEMPTS -eq 60 ]; do 61 | ATTEMPTS=$((ATTEMPTS + 1)) 62 | sleep 10 63 | done 64 | 65 | # Install Redis operator 66 | echo "[dev-env] creating redis-operator" 67 | kubectl create -f manifests/all-redis-operator-resources.yaml 68 | 69 | ATTEMPTS=0 70 | ROLLOUT_STATUS_CMD="kubectl rollout status deployment.apps/redisoperator" 71 | until $ROLLOUT_STATUS_CMD 2>/dev/null || [ $ATTEMPTS -eq 60 ]; do 72 | ATTEMPTS=$((ATTEMPTS + 1)) 73 | sleep 10 74 | done 75 | 76 | kubectl create -f manifests/persistent-storage-no-pvc-deletion.yaml -n example 77 | 78 | ATTEMPTS=0 79 | ROLLOUT_STATUS_CMD="kubectl rollout status deployment.apps/rfs-redisfailover-persistent-keep -n example" 80 | until $ROLLOUT_STATUS_CMD || [ $ATTEMPTS -eq 60 ]; do 81 | ATTEMPTS=$((ATTEMPTS + 1)) 82 | sleep 10 83 | done 84 | 85 | echo "[dev-env] Checking redis-operator statefulset replicas status to be ready" 86 | STATEFULSET_REPLICAS=$(kubectl get statefulset rfr-redisfailover-persistent-keep -o jsonpath='{.spec.replicas}' -n example) 87 | ATTEMPTS=0 88 | until [[ ${STATEFULSET_REPLICAS} -eq $(kubectl get statefulset rfr-redisfailover-persistent-keep -o jsonpath='{.status.readyReplicas}' -n example) ]] || [ $ATTEMPTS -eq 60 ]; do 89 | ATTEMPTS=$((ATTEMPTS + 1)) 90 | sleep 10 91 | done 92 | 93 | echo "[dev-env] installing example charts" 94 | helm upgrade --install \ 95 | example charts/example \ 96 | --namespace example \ 97 | --set ingress.host.name="example.${INGRESS_HOST}" 98 | 99 | ATTEMPTS=0 100 | ROLLOUT_STATUS_CMD="kubectl rollout status deployment/example -n example" 101 | until $ROLLOUT_STATUS_CMD || [ $ATTEMPTS -eq 60 ]; do 102 | ATTEMPTS=$((ATTEMPTS + 1)) 103 | sleep 10 104 | done 105 | 106 | cat < /dev/null; then 12 | echo "[image] Found docker-engine, begin building image." 13 | docker build -t example:"$TAG" . 14 | elif command -v podman &> /dev/null; then 15 | echo "[image] Found podman container engine, begin building image." 16 | podman build -t example:"$TAG" . 17 | else 18 | echo "[image] Neither docker nor podman container engine found." 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /build/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -n "$DEBUG" ]; then 4 | set -x 5 | fi 6 | 7 | set -o errexit 8 | set -o nounset 9 | set -o pipefail 10 | 11 | PYTHON="${PYTHON:=NOT_SET}" 12 | if [[ $PYTHON == "NOT_SET" ]]; then 13 | if command -v python3 &> /dev/null; then 14 | PYTHON=python3 15 | elif command -v python &> /dev/null; then 16 | PYTHON=python 17 | else 18 | echo "[install] Python is not installed." 19 | exit 1 20 | fi 21 | fi 22 | 23 | PYTHON_MAJOR_VERSION=$($PYTHON -c 'import sys; print(sys.version_info[0])') 24 | PYTHON_MINOR_VERSION=$($PYTHON -c 'import sys; print(sys.version_info[1])') 25 | if [[ "$PYTHON_MAJOR_VERSION" -lt 3 ]] || [[ "$PYTHON_MINOR_VERSION" -lt 8 ]]; then 26 | echo "[install] Python version 3.8.0 or higher is required." 27 | exit 1 28 | fi 29 | 30 | POETRY_HOME="${POETRY_HOME:=${HOME}/.local/share/pypoetry}" 31 | POETRY_BINARY="${POETRY_BINARY:=${POETRY_HOME}/venv/bin/poetry}" 32 | POETRY_VERSION="${POETRY_VERSION:=1.3.2}" 33 | if ! command -v "$POETRY_BINARY" &> /dev/null; then 34 | echo "[install] Poetry is not installed. Begin download and install." 35 | curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/1.3.2/install-poetry.py | POETRY_HOME=$POETRY_HOME POETRY_VERSION=$POETRY_VERSION $PYTHON - 36 | fi 37 | 38 | POETRY_INSTALL_OPTS="${POETRY_INSTALL_OPTS:="--no-interaction"}" 39 | echo "[install] Begin installing project." 40 | "$POETRY_BINARY" install $POETRY_INSTALL_OPTS 41 | 42 | cat << 'EOF' 43 | Project successfully installed. 44 | To activate virtualenv run: $ poetry shell 45 | Now you should access CLI script: $ example --help 46 | Alternatively you can access CLI script via poetry run: $ poetry run example --help 47 | To deactivate virtualenv simply type: $ deactivate 48 | To activate shell completion: 49 | - for bash: $ echo 'eval "$(_EXAMPLE_COMPLETE=source_bash example)' >> ~/.bashrc 50 | - for zsh: $ echo 'eval "$(_EXAMPLE_COMPLETE=source_zsh example)' >> ~/.zshrc 51 | - for fish: $ echo 'eval "$(_EXAMPLE_COMPLETE=source_fish example)' >> ~/.config/fish/completions/example.fish 52 | EOF -------------------------------------------------------------------------------- /charts/example/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/example/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: example 3 | description: A Helm chart for Kubernetes 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.2.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "0.1.0" 25 | -------------------------------------------------------------------------------- /charts/example/README.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | ![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.1.0](https://img.shields.io/badge/AppVersion-0.1.0-informational?style=flat-square) 4 | 5 | A Helm chart for Kubernetes 6 | 7 | ## Values 8 | 9 | | Key | Type | Default | Description | 10 | |-----|------|---------|-------------| 11 | | affinity | object | `{}` | | 12 | | autoscaling.enabled | bool | `false` | | 13 | | autoscaling.maxReplicas | int | `100` | | 14 | | autoscaling.minReplicas | int | `1` | | 15 | | autoscaling.targetCPUUtilizationPercentage | int | `80` | | 16 | | configMap.debug | string | `"false"` | | 17 | | configMap.redisHost | string | `"rfs-redisfailover-persistent-keep"` | | 18 | | configMap.redisPort | string | `"26379"` | | 19 | | configMap.redisUseSentinel | string | `"true"` | | 20 | | configMap.useRedis | string | `"true"` | | 21 | | fullnameOverride | string | `""` | | 22 | | image.pullPolicy | string | `"IfNotPresent"` | | 23 | | image.repository | string | `"example"` | | 24 | | image.tag | string | `""` | | 25 | | imagePullSecrets | list | `[]` | | 26 | | ingress.annotations."kubernetes.io/ingress.class" | string | `"nginx"` | | 27 | | ingress.enabled | bool | `true` | | 28 | | ingress.host.name | string | `"example.kubernetes.local"` | | 29 | | ingress.host.paths[0] | string | `"/"` | | 30 | | nameOverride | string | `""` | | 31 | | nodeSelector | object | `{}` | | 32 | | podAnnotations | object | `{}` | | 33 | | podSecurityContext | object | `{}` | | 34 | | replicaCount | int | `1` | | 35 | | resources | object | `{}` | | 36 | | securityContext | object | `{}` | | 37 | | service.port | int | `8000` | | 38 | | service.targetPort | int | `8000` | | 39 | | service.type | string | `"ClusterIP"` | | 40 | | serviceAccount.annotations | object | `{}` | | 41 | | serviceAccount.create | bool | `true` | | 42 | | serviceAccount.name | string | `""` | | 43 | | tolerations | list | `[]` | | 44 | 45 | ---------------------------------------------- 46 | Autogenerated from chart metadata using [helm-docs v1.5.0](https://github.com/norwoodj/helm-docs/releases/v1.5.0) 47 | -------------------------------------------------------------------------------- /charts/example/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "example.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "example.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "example.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "example.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /charts/example/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "example.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "example.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "example.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "example.labels" -}} 37 | helm.sh/chart: {{ include "example.chart" . }} 38 | {{ include "example.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "example.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "example.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "example.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "example.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /charts/example/templates/configmap.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "example.fullname" . }} 5 | labels: 6 | {{- include "example.labels" . | nindent 4 }} 7 | data: 8 | fastapi_debug: "{{ .Values.configMap.debug }}" 9 | fastapi_use_redis: "{{ .Values.configMap.useRedis }}" 10 | fastapi_redis_host: "{{ .Values.configMap.redisHost }}" 11 | fastapi_redis_port: "{{ .Values.configMap.redisPort }}" 12 | fastapi_redis_use_sentinel: "{{ .Values.configMap.redisUseSentinel }}" 13 | -------------------------------------------------------------------------------- /charts/example/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "example.fullname" . }} 5 | labels: 6 | {{- include "example.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "example.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "example.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "example.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | securityContext: 33 | {{- toYaml .Values.securityContext | nindent 12 }} 34 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 35 | imagePullPolicy: {{ .Values.image.pullPolicy }} 36 | ports: 37 | - containerPort: 8000 38 | protocol: TCP 39 | env: 40 | - name: FASTAPI_DEBUG 41 | valueFrom: 42 | configMapKeyRef: 43 | name: {{ include "example.fullname" . }} 44 | key: fastapi_debug 45 | - name: FASTAPI_USE_REDIS 46 | valueFrom: 47 | configMapKeyRef: 48 | name: {{ include "example.fullname" . }} 49 | key: fastapi_use_redis 50 | - name: FASTAPI_REDIS_HOST 51 | valueFrom: 52 | configMapKeyRef: 53 | name: {{ include "example.fullname" . }} 54 | key: fastapi_redis_host 55 | - name: FASTAPI_REDIS_PORT 56 | valueFrom: 57 | configMapKeyRef: 58 | name: {{ include "example.fullname" . }} 59 | key: fastapi_redis_port 60 | - name: FASTAPI_REDIS_USE_SENTINEL 61 | valueFrom: 62 | configMapKeyRef: 63 | name: {{ include "example.fullname" . }} 64 | key: fastapi_redis_use_sentinel 65 | livenessProbe: 66 | tcpSocket: 67 | port: 8000 68 | readinessProbe: 69 | httpGet: 70 | path: /api/ready 71 | port: 8000 72 | resources: 73 | {{- toYaml .Values.resources | nindent 12 }} 74 | {{- with .Values.nodeSelector }} 75 | nodeSelector: 76 | {{- toYaml . | nindent 8 }} 77 | {{- end }} 78 | {{- with .Values.affinity }} 79 | affinity: 80 | {{- toYaml . | nindent 8 }} 81 | {{- end }} 82 | {{- with .Values.tolerations }} 83 | tolerations: 84 | {{- toYaml . | nindent 8 }} 85 | {{- end }} 86 | -------------------------------------------------------------------------------- /charts/example/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "example.fullname" . }} 6 | labels: 7 | {{- include "example.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "example.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /charts/example/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "example.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | apiVersion: networking.k8s.io/v1beta1 5 | kind: Ingress 6 | metadata: 7 | name: {{ $fullName }} 8 | labels: 9 | {{ include "example.labels" . | indent 4 }} 10 | {{- with .Values.ingress.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | spec: 15 | rules: 16 | - host: {{ .Values.ingress.host.name | quote }} 17 | http: 18 | paths: 19 | {{- range .Values.ingress.host.paths }} 20 | - path: {{ . }} 21 | backend: 22 | serviceName: {{ $fullName }} 23 | servicePort: {{ $svcPort }} 24 | {{- end }} 25 | {{- end }} -------------------------------------------------------------------------------- /charts/example/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "example.fullname" . }} 5 | labels: 6 | {{- include "example.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: {{ .Values.service.targetPort }} 12 | protocol: TCP 13 | selector: 14 | {{- include "example.selectorLabels" . | nindent 4 }} 15 | -------------------------------------------------------------------------------- /charts/example/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "example.serviceAccountName" . }} 6 | labels: 7 | {{- include "example.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /charts/example/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "example.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "example.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "example.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /charts/example/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for example. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: example 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "" 12 | 13 | configMap: 14 | debug: "false" 15 | useRedis: "true" 16 | redisHost: "rfs-redisfailover-persistent-keep" 17 | redisPort: "26379" 18 | redisUseSentinel: "true" 19 | 20 | imagePullSecrets: [] 21 | nameOverride: "" 22 | fullnameOverride: "" 23 | 24 | serviceAccount: 25 | # Specifies whether a service account should be created 26 | create: true 27 | # Annotations to add to the service account 28 | annotations: {} 29 | # The name of the service account to use. 30 | # If not set and create is true, a name is generated using the fullname template 31 | name: "" 32 | 33 | podAnnotations: {} 34 | 35 | podSecurityContext: {} 36 | # fsGroup: 2000 37 | 38 | securityContext: {} 39 | # capabilities: 40 | # drop: 41 | # - ALL 42 | # readOnlyRootFilesystem: true 43 | # runAsNonRoot: true 44 | # runAsUser: 1000 45 | 46 | service: 47 | type: ClusterIP 48 | port: 8000 49 | targetPort: 8000 50 | 51 | ingress: 52 | enabled: true 53 | annotations: 54 | kubernetes.io/ingress.class: nginx 55 | host: 56 | name: example.kubernetes.local 57 | paths: [/] 58 | 59 | resources: {} 60 | # We usually recommend not to specify default resources and to leave this as a conscious 61 | # choice for the user. This also increases chances charts run on environments with little 62 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 63 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 64 | # limits: 65 | # cpu: 100m 66 | # memory: 128Mi 67 | # requests: 68 | # cpu: 100m 69 | # memory: 128Mi 70 | 71 | autoscaling: 72 | enabled: false 73 | minReplicas: 1 74 | maxReplicas: 100 75 | targetCPUUtilizationPercentage: 80 76 | # targetMemoryUtilizationPercentage: 80 77 | 78 | nodeSelector: {} 79 | 80 | tolerations: [] 81 | 82 | affinity: {} 83 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , python 3 | , poetry2nix 4 | }: 5 | 6 | poetry2nix.mkPoetryApplication { 7 | inherit python; 8 | 9 | projectDir = ./.; 10 | pyproject = ./pyproject.toml; 11 | poetrylock = ./poetry.lock; 12 | 13 | pythonImportsCheck = [ "example" ]; 14 | 15 | meta = with lib; { 16 | homepage = "https://github.com/fastapi-mvc/example"; 17 | description = "This project was generated with fastapi-mvc."; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | API 3 | === 4 | 5 | This part of the documentation lists the full API reference of all classes and functions. 6 | 7 | WSGI 8 | ---- 9 | 10 | .. autoclass:: example.wsgi.ApplicationLoader 11 | :members: 12 | :show-inheritance: 13 | 14 | Config 15 | ------ 16 | 17 | .. automodule:: example.config 18 | 19 | .. autoclass:: example.config.application.Application 20 | :members: 21 | :show-inheritance: 22 | 23 | .. autoclass:: example.config.redis.Redis 24 | :members: 25 | :show-inheritance: 26 | 27 | .. automodule:: example.config.gunicorn 28 | 29 | CLI 30 | --- 31 | 32 | .. automodule:: example.cli 33 | 34 | .. autofunction:: example.cli.cli.cli 35 | 36 | .. autofunction:: example.cli.utils.validate_directory 37 | 38 | .. autofunction:: example.cli.serve.serve 39 | 40 | App 41 | --- 42 | 43 | .. automodule:: example.app 44 | 45 | .. autofunction:: example.app.asgi.on_startup 46 | 47 | .. autofunction:: example.app.asgi.on_shutdown 48 | 49 | .. autofunction:: example.app.asgi.get_application 50 | 51 | .. automodule:: example.app.router 52 | 53 | Controllers 54 | ~~~~~~~~~~~ 55 | 56 | .. automodule:: example.app.controllers 57 | 58 | .. autofunction:: example.app.controllers.ready.readiness_check 59 | 60 | Models 61 | ~~~~~~ 62 | 63 | .. automodule:: example.app.models 64 | 65 | Views 66 | ~~~~~ 67 | 68 | .. automodule:: example.app.views 69 | 70 | .. autoclass:: example.app.views.error.ErrorModel 71 | :members: 72 | :show-inheritance: 73 | 74 | .. autoclass:: example.app.views.error.ErrorResponse 75 | :members: 76 | :show-inheritance: 77 | 78 | Exceptions 79 | ~~~~~~~~~~ 80 | 81 | .. automodule:: example.app.exceptions 82 | 83 | .. autoclass:: example.app.exceptions.http.HTTPException 84 | :members: 85 | :show-inheritance: 86 | 87 | .. autofunction:: example.app.exceptions.http.http_exception_handler 88 | 89 | Utils 90 | ~~~~~ 91 | 92 | .. automodule:: example.app.utils 93 | 94 | .. autoclass:: example.app.utils.aiohttp_client.AiohttpClient 95 | :members: 96 | :show-inheritance: 97 | 98 | .. autoclass:: example.app.utils.redis.RedisClient 99 | :members: 100 | :show-inheritance: 101 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.md 2 | :parser: myst_parser.sphinx_ 3 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | from example import __version__ 7 | from pallets_sphinx_themes import ProjectLink 8 | 9 | # Project -------------------------------------------------------------- 10 | 11 | project = "example" 12 | copyright = "2022, Radosław Szamszur" 13 | author = "Radosław Szamszur" 14 | release = __version__ 15 | 16 | # General -------------------------------------------------------------- 17 | 18 | extensions = [ 19 | "sphinx.ext.napoleon", 20 | "sphinx.ext.autodoc", 21 | "sphinx.ext.intersphinx", 22 | "pallets_sphinx_themes", 23 | "myst_parser", 24 | ] 25 | 26 | autodoc_typehints = "description" 27 | intersphinx_mapping = { 28 | "python": ("https://docs.python.org/3/", None), 29 | "click": ("https://click.palletsprojects.com/en/8.1.x/", None), 30 | } 31 | napoleon_google_docstring = True 32 | napoleon_numpy_docstring = False 33 | napoleon_include_init_with_doc = False 34 | napoleon_include_private_with_doc = True 35 | napoleon_attr_annotations = True 36 | 37 | 38 | templates_path = ["_templates"] 39 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 40 | 41 | # HTML ----------------------------------------------------------------- 42 | 43 | html_theme = "click" 44 | html_context = { 45 | "project_links": [ 46 | ProjectLink("Source Code", "https://github.com/fastapi-mvc/example"), 47 | ] 48 | } 49 | html_sidebars = { 50 | "index": ["project.html", "localtoc.html", "searchbox.html"], 51 | "**": ["localtoc.html", "relations.html", "searchbox.html"], 52 | } 53 | singlehtml_sidebars = {"index": ["project.html", "localtoc.html"]} 54 | html_logo = "_static/logo.png" 55 | html_title = f"example Documentation ({__version__})" 56 | html_show_sourcelink = False 57 | html_static_path = ["_static"] -------------------------------------------------------------------------------- /docs/deployment.rst: -------------------------------------------------------------------------------- 1 | Deployment 2 | ========== 3 | 4 | Will be added soon: `issue `__ 5 | 6 | In the meantime, `FastAPI deployment documentation `__ might be helpful. 7 | And, if you shall have any questions feel free to issue them `here `__. 8 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Documentation 2 | ============= 3 | 4 | -------------- 5 | 6 | **This project was generated with:** `fastapi-mvc `__ 7 | 8 | -------------- 9 | 10 | Quickstart 11 | ~~~~~~~~~~ 12 | 13 | If You want to go easy way and use provided virtualized environment You'll need to have installed: 14 | 15 | * rsync 16 | * Vagrant `(How to install vagrant) `__ 17 | * (Optional) Enabled virtualization in BIOS 18 | 19 | First run ``vagrant up`` in project root directory and enter virtualized environment using ``vagrant ssh`` 20 | Then run following commands to bootstrap local development cluster exposing ``fastapi-mvc`` application. 21 | 22 | .. code-block:: bash 23 | 24 | cd /syncd 25 | make dev-env 26 | 27 | .. note:: 28 | This process may take a while on first run. 29 | 30 | Once development cluster is up and running you should see summary listing application address: 31 | 32 | .. code-block:: bash 33 | 34 | Kubernetes cluster ready 35 | 36 | fastapi-mvc available under: http://example.192.168.49.2.nip.io/ 37 | 38 | You can delete dev-env by issuing: minikube delete 39 | 40 | .. note:: 41 | Above address may be different for your installation. 42 | 43 | Provided virtualized env doesn't have port forwarding configured which means, that bootstrapped application stack in k8s won't be accessible on Host OS. 44 | 45 | Deployed application stack in Kubernetes: 46 | 47 | .. code-block:: bash 48 | 49 | vagrant@ubuntu-focal:/syncd$ make dev-env 50 | ... 51 | ... 52 | ... 53 | Kubernetes cluster ready 54 | FastAPI available under: http://example.192.168.49.2.nip.io/ 55 | You can delete dev-env by issuing: make clean 56 | vagrant@ubuntu-focal:/syncd$ kubectl get all -n example 57 | NAME READY STATUS RESTARTS AGE 58 | pod/example-7f4dd8dc7f-p2kr7 1/1 Running 0 55s 59 | pod/rfr-redisfailover-persistent-keep-0 1/1 Running 0 3m39s 60 | pod/rfr-redisfailover-persistent-keep-1 1/1 Running 0 3m39s 61 | pod/rfr-redisfailover-persistent-keep-2 1/1 Running 0 3m39s 62 | pod/rfs-redisfailover-persistent-keep-5d46b5bcf8-2r7th 1/1 Running 0 3m39s 63 | pod/rfs-redisfailover-persistent-keep-5d46b5bcf8-6kqv5 1/1 Running 0 3m39s 64 | pod/rfs-redisfailover-persistent-keep-5d46b5bcf8-sgtvv 1/1 Running 0 3m39s 65 | 66 | NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE 67 | service/example ClusterIP 10.110.42.252 8000/TCP 56s 68 | service/rfs-redisfailover-persistent-keep ClusterIP 10.110.4.24 26379/TCP 3m39s 69 | 70 | NAME READY UP-TO-DATE AVAILABLE AGE 71 | deployment.apps/example 1/1 1 1 55s 72 | deployment.apps/rfs-redisfailover-persistent-keep 3/3 3 3 3m39s 73 | 74 | NAME DESIRED CURRENT READY AGE 75 | replicaset.apps/example-7f4dd8dc7f 1 1 1 55s 76 | replicaset.apps/rfs-redisfailover-persistent-keep-5d46b5bcf8 3 3 3 3m39s 77 | 78 | NAME READY AGE 79 | statefulset.apps/rfr-redisfailover-persistent-keep 3/3 3m39s 80 | 81 | NAME AGE 82 | redisfailover.databases.spotahome.com/redisfailover-persistent-keep 3m39s 83 | vagrant@ubuntu-focal:/syncd$ curl http://example.192.168.49.2.nip.io/api/ready 84 | {"status":"ok"} 85 | Documentation 86 | ------------- 87 | 88 | This part of the documentation guides you through all of the features and usage. 89 | 90 | .. toctree:: 91 | :maxdepth: 2 92 | 93 | install 94 | nix 95 | usage 96 | deployment 97 | 98 | API Reference 99 | ------------- 100 | 101 | If you are looking for information on a specific function, class, or 102 | method, this part of the documentation is for you. 103 | 104 | .. toctree:: 105 | :maxdepth: 2 106 | 107 | api 108 | 109 | Miscellaneous Pages 110 | ------------------- 111 | 112 | .. toctree:: 113 | :maxdepth: 2 114 | 115 | license 116 | changelog 117 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Application 5 | ----------- 6 | 7 | Prerequisites: 8 | 9 | * Python 3.8 or later `(How to install python) `__ 10 | * make 11 | * (optional) curl 12 | * (optional) Poetry `(How to install poetry) `__ 13 | 14 | To install fastapi-mvc from source first clone the repository and use ``make install`` target: 15 | 16 | .. code-block:: bash 17 | 18 | make install 19 | 20 | By default ``make install`` target will search first for ``python3`` then ``python`` executable in your ``PATH``. 21 | If needed this can be overridden by ``PYTHON`` environment variable. 22 | 23 | .. code-block:: bash 24 | 25 | export PYTHON=/path/to/my/python 26 | make install 27 | 28 | Lastly if Poetry is not found in its default installation directory (${HOME}/.local/share/pypoetry) this target will install it for you. 29 | However, one can always point to existing/customize Poetry installation with `environment variables `__: 30 | 31 | .. code-block:: bash 32 | 33 | export POETRY_HOME=/custom/poetry/path 34 | export POETRY_CACHE_DIR=/custom/poetry/path/cache 35 | export POETRY_VIRTUALENVS_IN_PROJECT=true 36 | make install 37 | 38 | Or using Poetry directly, should you choose: 39 | 40 | .. code-block:: bash 41 | 42 | poetry install 43 | 44 | Infrastructure 45 | -------------- 46 | 47 | Prerequisites: 48 | 49 | * make 50 | * gcc 51 | * golang 52 | * minikube version 1.22.0 `(How to install minikube) `__ 53 | * helm version 3.0.0 or higher `(How to install helm) `__ 54 | * kubectl version 1.16 up to 1.20.8 `(How to install kubectl) `__ 55 | * Container runtime interface. 56 | 57 | .. note:: 58 | Makefile dev-env target uses docker for minikube, for other CRI you'll need to modify this line in ``build/dev-env.sh`` ``MINIKUBE_IN_STYLE=0 minikube start --driver=docker 2>/dev/null`` 59 | 60 | To bootstrap local minikube Kubernetes cluster exposing ``example`` application run: 61 | 62 | .. code-block:: bash 63 | 64 | make dev-env 65 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | .. include:: ../LICENSE 5 | -------------------------------------------------------------------------------- /docs/nix.rst: -------------------------------------------------------------------------------- 1 | Using Nix 2 | ========= 3 | 4 | Installation 5 | ------------ 6 | 7 | Prerequisites: 8 | 9 | * Nix 2.8.x or later installed `(How to install Nix) `__ 10 | 11 | First `enable Nix flakes `__ if needed. 12 | 13 | Optionally `setup fastapi-mvc Nix binary cache `__ to speed up the build process: 14 | 15 | .. code-block:: bash 16 | 17 | nix-env -iA cachix -f https://cachix.org/api/v1/install 18 | cachix use fastapi-mvc 19 | 20 | To build default package run: 21 | 22 | .. code-block:: bash 23 | 24 | nix build .#default 25 | 26 | Or with concrete Python version, should you choose: 27 | 28 | .. code-block:: bash 29 | 30 | # Build with Python38 31 | nix build .#example-py38 32 | # Build with Python39 33 | nix build .#example-py39 34 | # Build with Python310 35 | nix build .#example-py310 36 | 37 | Lastly, to spawn shell for development environment run: 38 | 39 | .. code-block:: bash 40 | 41 | nix develop .#default 42 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | CLI 5 | --- 6 | 7 | This package exposes simple CLI for easier interaction: 8 | 9 | .. code-block:: bash 10 | 11 | $ example --help 12 | Usage: example [OPTIONS] COMMAND [ARGS]... 13 | 14 | Example CLI root. 15 | 16 | Options: 17 | -v, --verbose Enable verbose logging. 18 | --help Show this message and exit. 19 | 20 | Commands: 21 | serve example CLI serve command. 22 | $ example serve --help 23 | Usage: example serve [OPTIONS] 24 | 25 | Run production gunicorn (WSGI) server with uvicorn (ASGI) workers. 26 | 27 | Options: 28 | --bind TEXT Host to bind. 29 | -w, --workers INTEGER RANGE The number of worker processes for handling 30 | requests. 31 | -D, --daemon Daemonize the Gunicorn process. 32 | -e, --env TEXT Set environment variables in the execution 33 | environment. 34 | --pid PATH Specifies the PID file. 35 | --help Show this message and exit. 36 | 37 | .. note:: 38 | Maximum number of workers may be different in your case, it's limited to ``multiprocessing.cpu_count()`` 39 | 40 | WSGI + ASGI production server 41 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 42 | 43 | To run production unicorn + uvicorn (WSGI + ASGI) server you can use project CLI serve command: 44 | 45 | .. code-block:: bash 46 | 47 | example serve 48 | [2022-04-23 20:21:49 +0000] [4769] [INFO] Start gunicorn WSGI with ASGI workers. 49 | [2022-04-23 20:21:49 +0000] [4769] [INFO] Starting gunicorn 20.1.0 50 | [2022-04-23 20:21:49 +0000] [4769] [INFO] Listening at: http://127.0.0.1:8000 (4769) 51 | [2022-04-23 20:21:49 +0000] [4769] [INFO] Using worker: uvicorn.workers.UvicornWorker 52 | [2022-04-23 20:21:49 +0000] [4769] [INFO] Server is ready. Spawning workers 53 | [2022-04-23 20:21:49 +0000] [4771] [INFO] Booting worker with pid: 4771 54 | [2022-04-23 20:21:49 +0000] [4771] [INFO] Worker spawned (pid: 4771) 55 | [2022-04-23 20:21:49 +0000] [4771] [INFO] Started server process [4771] 56 | [2022-04-23 20:21:49 +0000] [4771] [INFO] Waiting for application startup. 57 | [2022-04-23 20:21:49 +0000] [4771] [INFO] Application startup complete. 58 | [2022-04-23 20:21:49 +0000] [4772] [INFO] Booting worker with pid: 4772 59 | [2022-04-23 20:21:49 +0000] [4772] [INFO] Worker spawned (pid: 4772) 60 | [2022-04-23 20:21:49 +0000] [4772] [INFO] Started server process [4772] 61 | [2022-04-23 20:21:49 +0000] [4772] [INFO] Waiting for application startup. 62 | [2022-04-23 20:21:49 +0000] [4772] [INFO] Application startup complete. 63 | 64 | To confirm it's working: 65 | 66 | .. code-block:: bash 67 | 68 | $ curl localhost:8000/api/ready 69 | {"status":"ok"} 70 | 71 | Dockerfile 72 | ---------- 73 | 74 | This project provides Dockerfile for containerized environment. 75 | 76 | .. code-block:: bash 77 | 78 | $ make image 79 | $ podman run -dit --name example -p 8000:8000 example:$(cat TAG) 80 | f41e5fa7ffd512aea8f1aad1c12157bf1e66f961aeb707f51993e9ac343f7a4b 81 | $ podman ps 82 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 83 | f41e5fa7ffd5 localhost/example:0.1.0 /usr/bin/fastapi ... 2 seconds ago Up 3 seconds ago 0.0.0.0:8000->8000/tcp example 84 | $ curl localhost:8000/api/ready 85 | {"status":"ok"} 86 | 87 | .. note:: 88 | Replace podman with docker if it's yours containerization engine. 89 | 90 | Development 91 | ----------- 92 | 93 | You can implement your own web routes logic straight away in ``example.controllers`` submodule. For more information please see `FastAPI documentation `__. 94 | 95 | Makefile 96 | ~~~~~~~~ 97 | 98 | Provided Makefile is a starting point for application and infrastructure development: 99 | 100 | .. code-block:: bash 101 | 102 | Usage: 103 | make 104 | help Display this help 105 | image Build example image 106 | clean-image Clean example image 107 | install Install example with poetry 108 | metrics Run example metrics checks 109 | unit-test Run example unit tests 110 | integration-test Run example integration tests 111 | docs Build example documentation 112 | dev-env Start a local Kubernetes cluster using minikube and deploy application 113 | clean Remove .cache directory and cached minikube 114 | 115 | Utilities 116 | ~~~~~~~~~ 117 | 118 | Available utilities: 119 | 120 | * RedisClient ``example.app.utils.redis`` 121 | * AiohttpClient ``example.app.utils.aiohttp_client`` 122 | 123 | They're initialized in ``asgi.py`` on FastAPI startup event handler: 124 | 125 | .. code-block:: python 126 | :emphasize-lines: 11, 13, 25, 27 127 | 128 | async def on_startup(): 129 | """Fastapi startup event handler. 130 | 131 | Creates RedisClient and AiohttpClient session. 132 | 133 | """ 134 | log.debug("Execute FastAPI startup event handler.") 135 | # Initialize utilities for whole FastAPI application without passing object 136 | # instances within the logic. Feel free to disable it if you don't need it. 137 | if settings.USE_REDIS: 138 | await RedisClient.open_redis_client() 139 | 140 | AiohttpClient.get_aiohttp_client() 141 | 142 | 143 | async def on_shutdown(): 144 | """Fastapi shutdown event handler. 145 | 146 | Destroys RedisClient and AiohttpClient session. 147 | 148 | """ 149 | log.debug("Execute FastAPI shutdown event handler.") 150 | # Gracefully close utilities. 151 | if settings.USE_REDIS: 152 | await RedisClient.close_redis_client() 153 | 154 | await AiohttpClient.close_aiohttp_client() 155 | 156 | and are available for whole application scope without passing object instances. In order to utilize it just execute classmethods directly. 157 | 158 | Example: 159 | 160 | .. code-block:: python 161 | 162 | from example.app.utils import RedisClient 163 | 164 | response = RedisClient.get("Key") 165 | 166 | Exceptions 167 | ~~~~~~~~~~ 168 | 169 | **HTTPException and handler** 170 | 171 | .. literalinclude:: ../example/app/exceptions/http.py 172 | :language: python 173 | 174 | This exception combined with ``http_exception_handler`` method allows you to use it the same manner as you'd use ``FastAPI.HTTPException`` with one difference. 175 | You have freedom to define returned response body, whereas in ``FastAPI.HTTPException`` content is returned under "detail" JSON key. 176 | In this application custom handler is added in ``asgi.py`` while initializing FastAPI application. This is needed in order to handle it globally. 177 | 178 | Web Routes 179 | ~~~~~~~~~~ 180 | 181 | All routes documentation is available on: 182 | 183 | * ``/`` with Swagger 184 | * ``/redoc`` or ReDoc. 185 | 186 | 187 | Configuration 188 | ------------- 189 | 190 | This application provides flexibility of configuration. All significant settings are defined by the environment variables, each with the default value. 191 | Moreover, package CLI allows overriding core ones: host, port, workers. You can modify all other available configuration settings in the gunicorn.conf.py file. 192 | 193 | Priority of overriding configuration: 194 | 195 | 1. cli 196 | 2. environment variables 197 | 3. ``gunicorn.py`` 198 | 199 | All application configuration is available in ``example.config`` submodule. 200 | 201 | Environment variables 202 | ~~~~~~~~~~~~~~~~~~~~~ 203 | 204 | **Application configuration** 205 | 206 | .. list-table:: 207 | :widths: 25 25 50 208 | :header-rows: 1 209 | 210 | * - Key 211 | - Default 212 | - Description 213 | * - FASTAPI_BIND 214 | - ``"127.0.0.1:8000"`` 215 | - The socket to bind. A string of the form: 'HOST', 'HOST:PORT', 'unix:PATH'. An IP is a valid HOST. 216 | * - FASTAPI_WORKERS 217 | - ``"2"`` 218 | - Number of gunicorn workers (uvicorn.workers.UvicornWorker). 219 | * - FASTAPI_DEBUG 220 | - ``"True"`` 221 | - FastAPI logging level. You should disable this for production. 222 | * - FASTAPI_PROJECT_NAME 223 | - ``"example"`` 224 | - FastAPI project name. 225 | * - FASTAPI_VERSION 226 | - ``"0.1.0"`` 227 | - Application version. 228 | * - FASTAPI_DOCS_URL 229 | - ``"/"`` 230 | - Path where swagger ui will be served at. 231 | * - FASTAPI_USE_REDIS 232 | - ``"False"`` 233 | - Whether or not to use Redis. 234 | * - FASTAPI_GUNICORN_LOG_LEVEL 235 | - ``"info"`` 236 | - The granularity of gunicorn log output. 237 | * - FASTAPI_GUNICORN_LOG_FORMAT 238 | - ``'%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'`` 239 | - Gunicorn log format. 240 | 241 | **Redis configuration** 242 | 243 | .. list-table:: 244 | :widths: 25 25 50 245 | :header-rows: 1 246 | 247 | * - Key 248 | - Default 249 | - Description 250 | * - FASTAPI_REDIS_HOTS 251 | - ``"127.0.0.1"`` 252 | - Redis host. 253 | * - FASTAPI_REDIS_PORT 254 | - ``"6379"`` 255 | - Redis port. 256 | * - FASTAPI_REDIS_USERNAME 257 | - ``""`` 258 | - Redis username. 259 | * - FASTAPI_REDIS_PASSWORD 260 | - ``""`` 261 | - Redis password. 262 | * - FASTAPI_REDIS_USE_SENTINEL 263 | - ``"False"`` 264 | - If provided Redis config is for Sentinel. 265 | 266 | Gunicorn 267 | ~~~~~~~~ 268 | 269 | `Gunicorn configuration file documentation `__ 270 | 271 | .. literalinclude:: ../example/config/gunicorn.py 272 | :language: python 273 | 274 | Routes 275 | ~~~~~~ 276 | 277 | Endpoints are defined in ``example.app.router`` submodule. Just simply import your controller and include it to FastAPI router: 278 | 279 | .. literalinclude:: ../example/app/router.py 280 | :language: python 281 | -------------------------------------------------------------------------------- /editable.nix: -------------------------------------------------------------------------------- 1 | { python 2 | , poetry2nix 3 | }: 4 | 5 | poetry2nix.mkPoetryEnv { 6 | inherit python; 7 | 8 | projectDir = ./.; 9 | pyproject = ./pyproject.toml; 10 | poetrylock = ./poetry.lock; 11 | 12 | editablePackageSources = { 13 | app = ./.; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | """This project was generated with fastapi-mvc.""" 2 | import logging 3 | 4 | from example.wsgi import ApplicationLoader 5 | from example.version import __version__ 6 | 7 | # initialize logging 8 | log = logging.getLogger(__name__) 9 | log.addHandler(logging.NullHandler()) 10 | 11 | __all__ = ("ApplicationLoader", "__version__") 12 | -------------------------------------------------------------------------------- /example/__main__.py: -------------------------------------------------------------------------------- 1 | """This project was generated with fastapi-mvc.""" 2 | from example.cli import cli 3 | 4 | 5 | if __name__ == "__main__": 6 | cli() 7 | -------------------------------------------------------------------------------- /example/app/__init__.py: -------------------------------------------------------------------------------- 1 | """Application implementation. 2 | 3 | The ``app`` submodule defines application controllers, models, views, utils, 4 | exceptions, and all other implementations for the need of your application. 5 | 6 | Resources: 7 | 1. `FastAPI documentation`_ 8 | 2. `Pydantic documentation`_ 9 | 10 | .. _FastAPI documentation: 11 | https://fastapi.tiangolo.com 12 | 13 | .. _Pydantic documentation: 14 | https://pydantic-docs.helpmanual.io/ 15 | 16 | """ 17 | from example.app.asgi import get_application 18 | 19 | 20 | __all__ = ("get_application",) 21 | -------------------------------------------------------------------------------- /example/app/asgi.py: -------------------------------------------------------------------------------- 1 | """Application implementation - ASGI.""" 2 | import logging 3 | 4 | from fastapi import FastAPI 5 | from example.config import settings 6 | from example.app.router import root_api_router 7 | from example.app.utils import RedisClient, AiohttpClient 8 | from example.app.exceptions import ( 9 | HTTPException, 10 | http_exception_handler, 11 | ) 12 | 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | async def on_startup() -> None: 18 | """Define FastAPI startup event handler. 19 | 20 | Resources: 21 | 1. https://fastapi.tiangolo.com/advanced/events/#startup-event 22 | 23 | """ 24 | log.debug("Execute FastAPI startup event handler.") 25 | if settings.USE_REDIS: 26 | await RedisClient.open_redis_client() 27 | 28 | AiohttpClient.get_aiohttp_client() 29 | 30 | 31 | async def on_shutdown() -> None: 32 | """Define FastAPI shutdown event handler. 33 | 34 | Resources: 35 | 1. https://fastapi.tiangolo.com/advanced/events/#shutdown-event 36 | 37 | """ 38 | log.debug("Execute FastAPI shutdown event handler.") 39 | # Gracefully close utilities. 40 | if settings.USE_REDIS: 41 | await RedisClient.close_redis_client() 42 | 43 | await AiohttpClient.close_aiohttp_client() 44 | 45 | 46 | def get_application() -> FastAPI: 47 | """Initialize FastAPI application. 48 | 49 | Returns: 50 | FastAPI: Application object instance. 51 | 52 | """ 53 | log.debug("Initialize FastAPI application node.") 54 | app = FastAPI( 55 | title=settings.PROJECT_NAME, 56 | debug=settings.DEBUG, 57 | version=settings.VERSION, 58 | docs_url=settings.DOCS_URL, 59 | on_startup=[on_startup], 60 | on_shutdown=[on_shutdown], 61 | ) 62 | log.debug("Add application routes.") 63 | app.include_router(root_api_router) 64 | log.debug("Register global exception handler for custom HTTPException.") 65 | app.add_exception_handler(HTTPException, http_exception_handler) 66 | 67 | return app 68 | -------------------------------------------------------------------------------- /example/app/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | """Application implementation - controllers.""" 2 | -------------------------------------------------------------------------------- /example/app/controllers/ready.py: -------------------------------------------------------------------------------- 1 | """Application implementation - Ready controller.""" 2 | import logging 3 | 4 | from fastapi import APIRouter 5 | from example.config import settings 6 | from example.app.utils import RedisClient 7 | from example.app.views import ReadyResponse, ErrorResponse 8 | from example.app.exceptions import HTTPException 9 | 10 | 11 | router = APIRouter() 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | @router.get( 16 | "/ready", 17 | tags=["ready"], 18 | response_model=ReadyResponse, 19 | summary="Simple health check.", 20 | status_code=200, 21 | responses={502: {"model": ErrorResponse}}, 22 | ) 23 | async def readiness_check() -> ReadyResponse: 24 | """Run basic application health check. 25 | 26 | If the application is up and running then this endpoint will return simple 27 | response with status ok. Moreover, if it has Redis enabled then connection 28 | to it will be tested. If Redis ping fails, then this endpoint will return 29 | 502 HTTP error. 30 | \f 31 | 32 | Returns: 33 | response (ReadyResponse): ReadyResponse model object instance. 34 | 35 | Raises: 36 | HTTPException: If applications has enabled Redis and can not connect 37 | to it. NOTE! This is the custom exception, not to be mistaken with 38 | FastAPI.HTTPException class. 39 | 40 | """ 41 | log.info("Started GET /ready") 42 | 43 | if settings.USE_REDIS and not await RedisClient.ping(): 44 | log.error("Could not connect to Redis") 45 | raise HTTPException( 46 | status_code=502, 47 | content=ErrorResponse(code=502, message="Could not connect to Redis").dict( 48 | exclude_none=True 49 | ), 50 | ) 51 | 52 | return ReadyResponse(status="ok") 53 | -------------------------------------------------------------------------------- /example/app/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """Application implementation - exceptions.""" 2 | from example.app.exceptions.http import ( 3 | HTTPException, 4 | http_exception_handler, 5 | ) 6 | 7 | 8 | __all__ = ("HTTPException", "http_exception_handler") 9 | -------------------------------------------------------------------------------- /example/app/exceptions/http.py: -------------------------------------------------------------------------------- 1 | """Application implementation - custom FastAPI HTTP exception with handler.""" 2 | from typing import Any, Optional, Dict 3 | 4 | from fastapi import Request 5 | from fastapi.responses import JSONResponse 6 | 7 | 8 | class HTTPException(Exception): 9 | """Define custom HTTPException class definition. 10 | 11 | This exception combined with exception_handler method allows you to use it 12 | the same manner as you'd use FastAPI.HTTPException with one difference. You 13 | have freedom to define returned response body, whereas in 14 | FastAPI.HTTPException content is returned under "detail" JSON key. 15 | 16 | FastAPI.HTTPException source: 17 | https://github.com/tiangolo/fastapi/blob/master/fastapi/exceptions.py 18 | 19 | """ 20 | 21 | def __init__( 22 | self, 23 | status_code: int, 24 | content: Any = None, 25 | headers: Optional[Dict[str, Any]] = None, 26 | ) -> None: 27 | """Initialize HTTPException class object instance. 28 | 29 | Args: 30 | status_code (int): HTTP error status code. 31 | content (Any): Response body. 32 | headers (Optional[Dict[str, Any]]): Additional response headers. 33 | 34 | """ 35 | self.status_code = status_code 36 | self.content = content 37 | self.headers = headers 38 | 39 | def __repr__(self) -> str: 40 | """Class custom __repr__ method implementation. 41 | 42 | Returns: 43 | str: HTTPException string object. 44 | 45 | """ 46 | kwargs = [] 47 | 48 | for key, value in self.__dict__.items(): 49 | if not key.startswith("_"): 50 | kwargs.append(f"{key}={value!r}") 51 | 52 | return f"{self.__class__.__name__}({', '.join(kwargs)})" 53 | 54 | 55 | async def http_exception_handler( 56 | request: Request, exception: HTTPException 57 | ) -> JSONResponse: 58 | """Define custom HTTPException handler. 59 | 60 | In this application custom handler is added in asgi.py while initializing 61 | FastAPI application. This is needed in order to handle custom HTTException 62 | globally. 63 | 64 | More details: 65 | https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers 66 | 67 | Args: 68 | request (starlette.requests.Request): Request class object instance. 69 | More details: https://www.starlette.io/requests/ 70 | exception (HTTPException): Custom HTTPException class object instance. 71 | 72 | Returns: 73 | FastAPI.response.JSONResponse class object instance initialized with 74 | kwargs from custom HTTPException. 75 | 76 | """ 77 | return JSONResponse( 78 | status_code=exception.status_code, 79 | content=exception.content, 80 | headers=exception.headers, 81 | ) 82 | -------------------------------------------------------------------------------- /example/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Application implementation - models.""" 2 | -------------------------------------------------------------------------------- /example/app/router.py: -------------------------------------------------------------------------------- 1 | """Application configuration - root APIRouter. 2 | 3 | Defines all FastAPI application endpoints. 4 | 5 | Resources: 6 | 1. https://fastapi.tiangolo.com/tutorial/bigger-applications 7 | 8 | """ 9 | from fastapi import APIRouter 10 | from example.app.controllers import ready 11 | 12 | root_api_router = APIRouter(prefix="/api") 13 | 14 | root_api_router.include_router(ready.router, tags=["ready"]) 15 | -------------------------------------------------------------------------------- /example/app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Application implementation - utilities. 2 | 3 | Resources: 4 | 1. https://aioredis.readthedocs.io/en/latest/ 5 | 6 | """ 7 | from example.app.utils.aiohttp_client import AiohttpClient 8 | from example.app.utils.redis import RedisClient 9 | 10 | 11 | __all__ = ("AiohttpClient", "RedisClient") 12 | -------------------------------------------------------------------------------- /example/app/utils/aiohttp_client.py: -------------------------------------------------------------------------------- 1 | """Aiohttp client class utility.""" 2 | from typing import Dict, AnyStr, Any 3 | import logging 4 | import asyncio 5 | from typing import Optional 6 | from socket import AF_INET 7 | 8 | import aiohttp 9 | 10 | 11 | SIZE_POOL_AIOHTTP = 100 12 | 13 | 14 | class AiohttpClient(object): 15 | """Aiohttp session client utility. 16 | 17 | Utility class for handling HTTP async request for whole FastAPI application 18 | scope. 19 | 20 | Attributes: 21 | sem (asyncio.Semaphore, optional): Semaphore value. 22 | aiohttp_client (aiohttp.ClientSession, optional): Aiohttp client session 23 | object instance. 24 | 25 | """ 26 | 27 | sem: Optional[asyncio.Semaphore] = None 28 | aiohttp_client: Optional[aiohttp.ClientSession] = None 29 | log: logging.Logger = logging.getLogger(__name__) 30 | 31 | @classmethod 32 | def get_aiohttp_client(cls) -> aiohttp.ClientSession: 33 | """Create aiohttp client session object instance. 34 | 35 | Returns: 36 | aiohttp.ClientSession: ClientSession object instance. 37 | 38 | """ 39 | if cls.aiohttp_client is None: 40 | cls.log.debug("Initialize AiohttpClient session.") 41 | timeout = aiohttp.ClientTimeout(total=2) 42 | connector = aiohttp.TCPConnector( 43 | family=AF_INET, 44 | limit_per_host=SIZE_POOL_AIOHTTP, 45 | ) 46 | cls.aiohttp_client = aiohttp.ClientSession( 47 | timeout=timeout, 48 | connector=connector, 49 | ) 50 | 51 | return cls.aiohttp_client 52 | 53 | @classmethod 54 | async def close_aiohttp_client(cls) -> None: 55 | """Close aiohttp client session.""" 56 | if cls.aiohttp_client: 57 | cls.log.debug("Close AiohttpClient session.") 58 | await cls.aiohttp_client.close() 59 | cls.aiohttp_client = None 60 | 61 | @classmethod 62 | async def get( 63 | cls, 64 | url: str, 65 | headers: Dict[str, AnyStr] = None, 66 | raise_for_status: bool = False, 67 | ) -> aiohttp.ClientResponse: 68 | """Execute HTTP GET request. 69 | 70 | Args: 71 | url (str): HTTP GET request endpoint. 72 | headers (typing.Dict[str, typing.AnyStr]): Optional HTTP Headers to send 73 | with the request. 74 | raise_for_status (bool): Automatically call 75 | ClientResponse.raise_for_status() for response if set to True. 76 | 77 | Returns: 78 | response: HTTP GET request response - aiohttp.ClientResponse 79 | object instance. 80 | 81 | """ 82 | client = cls.get_aiohttp_client() 83 | 84 | cls.log.debug(f"Started GET {url}") 85 | response = await client.get( 86 | url, 87 | headers=headers, 88 | raise_for_status=raise_for_status, 89 | ) 90 | 91 | return response 92 | 93 | @classmethod 94 | async def post( 95 | cls, 96 | url: str, 97 | data: Optional[Any] = None, 98 | headers: Dict[str, AnyStr] = None, 99 | raise_for_status: bool = False, 100 | ) -> aiohttp.ClientResponse: 101 | """Execute HTTP POST request. 102 | 103 | Args: 104 | url (str): HTTP POST request endpoint. 105 | data (typing.Optional[typing.Any]): The data to send in the body of the 106 | request. This can be a FormData object or anything that can be passed 107 | into FormData, e.g. a dictionary, bytes, or file-like object. 108 | headers (typing.Dict[str, typing.AnyStr]): Optional HTTP Headers to send 109 | with the request. 110 | raise_for_status (bool): Automatically call 111 | ClientResponse.raise_for_status() for response if set to True. 112 | 113 | Returns: 114 | response: HTTP POST request response - aiohttp.ClientResponse 115 | object instance. 116 | 117 | """ 118 | client = cls.get_aiohttp_client() 119 | 120 | cls.log.debug(f"Started POST: {url}") 121 | response = await client.post( 122 | url, 123 | data=data, 124 | headers=headers, 125 | raise_for_status=raise_for_status, 126 | ) 127 | 128 | return response 129 | 130 | @classmethod 131 | async def put( 132 | cls, 133 | url: str, 134 | data: Optional[Any] = None, 135 | headers: Dict[str, AnyStr] = None, 136 | raise_for_status: bool = False, 137 | ) -> aiohttp.ClientResponse: 138 | """Execute HTTP PUT request. 139 | 140 | Args: 141 | url (str): HTTP PUT request endpoint. 142 | data (typing.Optional[typing.Any]): The data to send in the body of the 143 | request. This can be a FormData object or anything that can be passed 144 | into FormData, e.g. a dictionary, bytes, or file-like object. 145 | headers (typing.Dict[str, typing.AnyStr]): Optional HTTP Headers to send 146 | with the request. 147 | raise_for_status (bool): Automatically call 148 | ClientResponse.raise_for_status() for response if set to True. 149 | 150 | Returns: 151 | response: HTTP PUT request response - aiohttp.ClientResponse 152 | object instance. 153 | 154 | """ 155 | client = cls.get_aiohttp_client() 156 | 157 | cls.log.debug(f"Started PUT: {url}") 158 | response = await client.put( 159 | url, 160 | data=data, 161 | headers=headers, 162 | raise_for_status=raise_for_status, 163 | ) 164 | 165 | return response 166 | 167 | @classmethod 168 | async def delete( 169 | cls, 170 | url: str, 171 | headers: Dict[str, AnyStr] = None, 172 | raise_for_status: bool = False, 173 | ) -> aiohttp.ClientResponse: 174 | """Execute HTTP DELETE request. 175 | 176 | Args: 177 | url (str): HTTP DELETE request endpoint. 178 | headers (typing.Dict[str, typing.AnyStr]): Optional HTTP Headers to send 179 | with the request. 180 | raise_for_status (bool): Automatically call 181 | ClientResponse.raise_for_status() for response if set to True. 182 | 183 | Returns: 184 | response: HTTP DELETE request response - aiohttp.ClientResponse 185 | object instance. 186 | 187 | """ 188 | client = cls.get_aiohttp_client() 189 | 190 | cls.log.debug(f"Started DELETE: {url}") 191 | response = await client.delete( 192 | url, 193 | headers=headers, 194 | raise_for_status=raise_for_status, 195 | ) 196 | 197 | return response 198 | 199 | @classmethod 200 | async def patch( 201 | cls, 202 | url: str, 203 | data: Optional[Any] = None, 204 | headers: Dict[str, AnyStr] = None, 205 | raise_for_status: bool = False, 206 | ) -> aiohttp.ClientResponse: 207 | """Execute HTTP PATCH request. 208 | 209 | Args: 210 | url (str): HTTP PATCH request endpoint. 211 | data (typing.Optional[typing.Any]): The data to send in the body of the 212 | request. This can be a FormData object or anything that can be passed 213 | into FormData, e.g. a dictionary, bytes, or file-like object. 214 | headers (typing.Dict[str, typing.AnyStr]): Optional HTTP Headers to send 215 | with the request. 216 | raise_for_status (bool): Automatically call 217 | ClientResponse.raise_for_status() for response if set to True. 218 | 219 | Returns: 220 | response: HTTP PATCH request response - aiohttp.ClientResponse 221 | object instance. 222 | 223 | """ 224 | client = cls.get_aiohttp_client() 225 | 226 | cls.log.debug(f"Started PATCH: {url}") 227 | response = await client.patch( 228 | url, 229 | data=data, 230 | headers=headers, 231 | raise_for_status=raise_for_status, 232 | ) 233 | 234 | return response 235 | -------------------------------------------------------------------------------- /example/app/utils/redis.py: -------------------------------------------------------------------------------- 1 | """Redis client class utility.""" 2 | from typing import TypeVar, Dict, Union, List, AnyStr 3 | import logging 4 | 5 | from redis import asyncio as aioredis 6 | from example.config import redis as redis_conf 7 | 8 | 9 | R = TypeVar("R") 10 | 11 | 12 | class RedisClient(object): 13 | """Define Redis utility. 14 | 15 | Utility class for handling Redis database connection and operations. 16 | 17 | Attributes: 18 | redis_client (aioredis.Redis, optional): Redis client object instance. 19 | log (logging.Logger): Logging handler for this class. 20 | base_redis_init_kwargs (typing.Dict[str, typing.Union[str, int]]): Common 21 | kwargs regardless other Redis configuration 22 | connection_kwargs (typing.Optional[typing.Dict[str, str]]): Extra kwargs 23 | for Redis object init. 24 | 25 | """ 26 | 27 | redis_client: aioredis.Redis = None 28 | log: logging.Logger = logging.getLogger(__name__) 29 | base_redis_init_kwargs: Dict[str, Union[str, int]] = { 30 | "encoding": "utf-8", 31 | "port": redis_conf.REDIS_PORT, 32 | } 33 | connection_kwargs: Dict[str, str] = {} 34 | 35 | @classmethod 36 | def open_redis_client(cls) -> aioredis.Redis: 37 | """Create Redis client session object instance. 38 | 39 | Based on configuration create either Redis client or Redis Sentinel. 40 | 41 | Returns: 42 | aioredis.Redis: Redis object instance. 43 | 44 | """ 45 | if cls.redis_client is None: 46 | cls.log.debug("Initialize Redis client.") 47 | if redis_conf.REDIS_USERNAME and redis_conf.REDIS_PASSWORD: 48 | cls.connection_kwargs = { 49 | "username": redis_conf.REDIS_USERNAME, 50 | "password": redis_conf.REDIS_PASSWORD, 51 | } 52 | 53 | if redis_conf.REDIS_USE_SENTINEL: 54 | sentinel = aioredis.sentinel.Sentinel( 55 | [(redis_conf.REDIS_HOST, redis_conf.REDIS_PORT)], 56 | sentinel_kwargs=cls.connection_kwargs, 57 | ) 58 | cls.redis_client = sentinel.master_for("mymaster") 59 | else: 60 | cls.base_redis_init_kwargs.update(cls.connection_kwargs) 61 | cls.redis_client = aioredis.from_url( 62 | f"redis://{redis_conf.REDIS_HOST}", 63 | **cls.base_redis_init_kwargs, 64 | ) 65 | 66 | return cls.redis_client 67 | 68 | @classmethod 69 | async def close_redis_client(cls) -> None: 70 | """Close Redis client.""" 71 | if cls.redis_client: 72 | cls.log.debug("Closing Redis client") 73 | await cls.redis_client.close() 74 | 75 | @classmethod 76 | async def ping(cls) -> bool: 77 | """Execute Redis PING command. 78 | 79 | Ping the Redis server. 80 | 81 | Returns: 82 | bool: Boolean, whether Redis client could ping Redis server. 83 | 84 | Raises: 85 | aioredis.RedisError: If Redis client failed while executing command. 86 | 87 | """ 88 | # Note: Not sure if this shouldn't be deep copy instead? 89 | redis_client = cls.redis_client 90 | 91 | cls.log.debug("Execute Redis PING command") 92 | try: 93 | return await redis_client.ping() 94 | except aioredis.RedisError as ex: 95 | cls.log.exception( 96 | "Redis PING command finished with exception", 97 | exc_info=(type(ex), ex, ex.__traceback__), 98 | ) 99 | return False 100 | 101 | @classmethod 102 | async def set(cls, key: str, value: str) -> R: 103 | """Execute Redis SET command. 104 | 105 | Set key to hold the string value. If key already holds a value, it is 106 | overwritten, regardless of its type. 107 | 108 | Args: 109 | key (str): Redis db key. 110 | value (str): Value to be set. 111 | 112 | Returns: 113 | response: Redis SET command response, for more info 114 | look: https://redis.io/commands/set#return-value 115 | 116 | Raises: 117 | aioredis.RedisError: If Redis client failed while executing command. 118 | 119 | """ 120 | redis_client = cls.redis_client 121 | 122 | cls.log.debug(f"Execute Redis SET command, key: {key}, value: {value}") 123 | try: 124 | return await redis_client.set(key, value) 125 | except aioredis.RedisError as ex: 126 | cls.log.exception( 127 | "Redis SET command finished with exception", 128 | exc_info=(type(ex), ex, ex.__traceback__), 129 | ) 130 | raise ex 131 | 132 | @classmethod 133 | async def rpush(cls, key: str, value: Union[str, List[AnyStr]]) -> int: 134 | """Execute Redis RPUSH command. 135 | 136 | Insert all the specified values at the tail of the list stored at key. 137 | If key does not exist, it is created as empty list before performing 138 | the push operation. When key holds a value that is not a list, an 139 | error is returned. 140 | 141 | Args: 142 | key (str): Redis db key. 143 | value (typing.Union[str, List[typing.AnyStr]]): Single or multiple 144 | values to append. 145 | 146 | Returns: 147 | int: Length of the list after the push operation. 148 | 149 | Raises: 150 | aioredis.RedisError: If Redis client failed while executing command. 151 | 152 | """ 153 | redis_client = cls.redis_client 154 | 155 | cls.log.debug(f"Execute Redis RPUSH command, key: {key}, value: {value}") 156 | try: 157 | return await redis_client.rpush(key, value) 158 | except aioredis.RedisError as ex: 159 | cls.log.exception( 160 | "Redis RPUSH command finished with exception", 161 | exc_info=(type(ex), ex, ex.__traceback__), 162 | ) 163 | raise ex 164 | 165 | @classmethod 166 | async def exists(cls, key: str) -> bool: 167 | """Execute Redis EXISTS command. 168 | 169 | Returns if key exists. 170 | 171 | Args: 172 | key (str): Redis db key. 173 | 174 | Returns: 175 | bool: Boolean whether key exists in Redis db. 176 | 177 | Raises: 178 | aioredis.RedisError: If Redis client failed while executing command. 179 | 180 | """ 181 | redis_client = cls.redis_client 182 | 183 | cls.log.debug(f"Execute Redis EXISTS command, key: {key}") 184 | try: 185 | return await redis_client.exists(key) 186 | except aioredis.RedisError as ex: 187 | cls.log.exception( 188 | "Redis EXISTS command finished with exception", 189 | exc_info=(type(ex), ex, ex.__traceback__), 190 | ) 191 | raise ex 192 | 193 | @classmethod 194 | async def get(cls, key: str) -> str: 195 | """Execute Redis GET command. 196 | 197 | Get the value of key. If the key does not exist the special value None 198 | is returned. An error is returned if the value stored at key is not a 199 | string, because GET only handles string values. 200 | 201 | Args: 202 | key (str): Redis db key. 203 | 204 | Returns: 205 | str: Value of key. 206 | 207 | Raises: 208 | aioredis.RedisError: If Redis client failed while executing command. 209 | 210 | """ 211 | redis_client = cls.redis_client 212 | 213 | cls.log.debug(f"Execute Redis GET command, key: {key}") 214 | try: 215 | return await redis_client.get(key) 216 | except aioredis.RedisError as ex: 217 | cls.log.exception( 218 | "Redis GET command finished with exception", 219 | exc_info=(type(ex), ex, ex.__traceback__), 220 | ) 221 | raise ex 222 | 223 | @classmethod 224 | async def lrange(cls, key: str, start: int, end: int) -> str: 225 | """Execute Redis LRANGE command. 226 | 227 | Returns the specified elements of the list stored at key. The offsets 228 | start and stop are zero-based indexes, with 0 being the first element 229 | of the list (the head of the list), 1 being the next element and so on. 230 | These offsets can also be negative numbers indicating offsets starting 231 | at the end of the list. For example, -1 is the last element of the 232 | list, -2 the penultimate, and so on. 233 | 234 | Args: 235 | key (str): Redis db key. 236 | start (int): Start offset value. 237 | end (int): End offset value. 238 | 239 | Returns: 240 | str: Returns the specified elements of the list stored at key. 241 | 242 | Raises: 243 | aioredis.RedisError: If Redis client failed while executing command. 244 | 245 | """ 246 | redis_client = cls.redis_client 247 | cls.log.debug( 248 | f"Execute Redis LRANGE command, key: {key}, start: {start}, end: {end}" 249 | ) 250 | try: 251 | return await redis_client.lrange(key, start, end) 252 | except aioredis.RedisError as ex: 253 | cls.log.exception( 254 | "Redis LRANGE command finished with exception", 255 | exc_info=(type(ex), ex, ex.__traceback__), 256 | ) 257 | raise ex 258 | -------------------------------------------------------------------------------- /example/app/views/__init__.py: -------------------------------------------------------------------------------- 1 | """Application implementation - views.""" 2 | from example.app.views.error import ErrorResponse 3 | from example.app.views.ready import ReadyResponse 4 | 5 | 6 | __all__ = ("ErrorResponse", "ReadyResponse") 7 | -------------------------------------------------------------------------------- /example/app/views/error.py: -------------------------------------------------------------------------------- 1 | """Application implementation - error response.""" 2 | from typing import Dict, Any, Optional, List, Union 3 | from http import HTTPStatus 4 | 5 | from pydantic import BaseModel, root_validator 6 | 7 | 8 | class ErrorModel(BaseModel): 9 | """Define base error model for the response. 10 | 11 | Attributes: 12 | code (int): HTTP error status code. 13 | message (str): Detail on HTTP error. 14 | status (str): HTTP error reason-phrase as per in RFC7235. NOTE! Set 15 | automatically based on HTTP error status code. 16 | 17 | Raises: 18 | pydantic.error_wrappers.ValidationError: If any of provided attribute 19 | doesn't pass type validation. 20 | 21 | """ 22 | 23 | code: int 24 | message: str 25 | details: Optional[List[Dict[str, Any]]] 26 | 27 | @root_validator(pre=False, skip_on_failure=True) 28 | def _set_status(cls, values: Dict[str, Any]) -> Dict[str, Any]: 29 | """Set the status field value based on the code attribute value. 30 | 31 | Args: 32 | values(typing.Dict[str, typing.Any]): Stores the attributes of the 33 | ErrorModel object. 34 | 35 | Returns: 36 | typing.Dict[str, typing.Any]: The attributes of the ErrorModel object 37 | with the status field. 38 | 39 | """ 40 | values["status"] = HTTPStatus(values["code"]).name 41 | return values 42 | 43 | class Config: 44 | """Config sub-class needed to extend/override the generated JSON schema. 45 | 46 | More details can be found in pydantic documentation: 47 | https://pydantic-docs.helpmanual.io/usage/schema/#schema-customization 48 | 49 | """ 50 | 51 | @staticmethod 52 | def schema_extra(schema: Dict[str, Any]) -> None: 53 | """Post-process the generated schema. 54 | 55 | Method can have one or two positional arguments. The first will be 56 | the schema dictionary. The second, if accepted, will be the model 57 | class. The callable is expected to mutate the schema dictionary 58 | in-place; the return value is not used. 59 | 60 | Args: 61 | schema (typing.Dict[str, typing.Any]): The schema dictionary. 62 | 63 | """ 64 | # Override schema description, by default is taken from docstring. 65 | schema["description"] = "Error model." 66 | # Add status to schema properties. 67 | schema["properties"].update( 68 | {"status": {"title": "Status", "type": "string"}} 69 | ) 70 | schema["required"].append("status") 71 | 72 | 73 | class ErrorResponse(BaseModel): 74 | """Define error response model. 75 | 76 | Attributes: 77 | error (ErrorModel): ErrorModel class object instance. 78 | 79 | Raises: 80 | pydantic.error_wrappers.ValidationError: If any of provided attribute 81 | doesn't pass type validation. 82 | 83 | """ 84 | 85 | error: ErrorModel 86 | 87 | def __init__(self, **kwargs: Union[int, str, List[Dict[str, Any]]]): 88 | """Initialize ErrorResponse class object instance.""" 89 | # Neat trick to still use kwargs on ErrorResponse model. 90 | super().__init__(error=ErrorModel(**kwargs)) 91 | 92 | class Config: 93 | """Config sub-class needed to extend/override the generated JSON schema. 94 | 95 | More details can be found in pydantic documentation: 96 | https://pydantic-docs.helpmanual.io/usage/schema/#schema-customization 97 | 98 | """ 99 | 100 | @staticmethod 101 | def schema_extra(schema: Dict[str, Any]) -> None: 102 | """Post-process the generated schema. 103 | 104 | Method can have one or two positional arguments. The first will be 105 | the schema dictionary. The second, if accepted, will be the model 106 | class. The callable is expected to mutate the schema dictionary 107 | in-place; the return value is not used. 108 | 109 | Args: 110 | schema (typing.Dict[str, typing.Any]): The schema dictionary. 111 | 112 | """ 113 | # Override schema description, by default is taken from docstring. 114 | schema["description"] = "Error response model." 115 | -------------------------------------------------------------------------------- /example/app/views/ready.py: -------------------------------------------------------------------------------- 1 | """Application implementation - ready response.""" 2 | from typing import Any, Dict 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class ReadyResponse(BaseModel): 8 | """Define ready response model. 9 | 10 | Attributes: 11 | status (str): Strings are accepted as-is, int float and Decimal are 12 | coerced using str(v), bytes and bytearray are converted using 13 | v.decode(), enums inheriting from str are converted using 14 | v.value, and all other types cause an error. 15 | 16 | Raises: 17 | pydantic.error_wrappers.ValidationError: If any of provided attribute 18 | doesn't pass type validation. 19 | 20 | """ 21 | 22 | status: str 23 | 24 | class Config: 25 | """Config sub-class needed to extend/override the generated JSON schema. 26 | 27 | More details can be found in pydantic documentation: 28 | https://pydantic-docs.helpmanual.io/usage/schema/#schema-customization 29 | 30 | """ 31 | 32 | @staticmethod 33 | def schema_extra(schema: Dict[str, Any]) -> None: 34 | """Post-process the generated schema. 35 | 36 | Method can have one or two positional arguments. The first will be 37 | the schema dictionary. The second, if accepted, will be the model 38 | class. The callable is expected to mutate the schema dictionary 39 | in-place; the return value is not used. 40 | 41 | Args: 42 | schema (typing.Dict[str, typing.Any]): The schema dictionary. 43 | 44 | """ 45 | # Override schema description, by default is taken from docstring. 46 | schema["description"] = "Ready response model." 47 | -------------------------------------------------------------------------------- /example/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Command-line interface. 2 | 3 | The ``cli`` submodule defines Click command-line interface root and its 4 | commands. 5 | 6 | Resources: 7 | 1. `Click documentation`_ 8 | 9 | .. _Click documentation: 10 | https://click.palletsprojects.com/en/8.1.x/ 11 | 12 | """ 13 | from example.cli.cli import cli 14 | 15 | 16 | __all__ = ("cli",) 17 | -------------------------------------------------------------------------------- /example/cli/cli.py: -------------------------------------------------------------------------------- 1 | """Command-line interface - root.""" 2 | from typing import Dict, Any 3 | import logging 4 | 5 | import click 6 | from example.cli.serve import serve 7 | 8 | 9 | cmd_help = "Example CLI root." 10 | 11 | 12 | @click.group(help=cmd_help) 13 | @click.option( 14 | "-v", 15 | "--verbose", 16 | help="Enable verbose logging.", 17 | is_flag=True, 18 | default=False, 19 | ) 20 | def cli(**options: Dict[str, Any]) -> None: 21 | """Define command-line interface root. 22 | 23 | Args: 24 | options (typing.Dict[str, typing.Any]): Map of command option names to 25 | their parsed values. 26 | 27 | """ 28 | if options["verbose"]: 29 | level = logging.DEBUG 30 | else: 31 | level = logging.INFO 32 | 33 | logging.basicConfig( 34 | level=level, 35 | format="[%(asctime)s] [%(process)s] [%(levelname)s] %(message)s", 36 | datefmt="%Y-%m-%d %H:%M:%S %z", 37 | ) 38 | 39 | 40 | cli.add_command(serve) 41 | -------------------------------------------------------------------------------- /example/cli/serve.py: -------------------------------------------------------------------------------- 1 | """Command-line interface - serve command.""" 2 | from typing import Dict, Any 3 | from multiprocessing import cpu_count 4 | 5 | import click 6 | from example import ApplicationLoader 7 | from example.app import get_application 8 | from example.cli.utils import validate_directory 9 | 10 | 11 | cmd_short_help = "Run production server." 12 | cmd_help = """\ 13 | Run production gunicorn (WSGI) server with uvicorn (ASGI) workers. 14 | """ 15 | 16 | 17 | @click.command( 18 | help=cmd_help, 19 | short_help=cmd_short_help, 20 | ) 21 | @click.option( 22 | "--bind", 23 | help="""\ 24 | The socket to bind. 25 | A string of the form: 'HOST', 'HOST:PORT', 'unix:PATH'. 26 | An IP is a valid HOST. 27 | """, 28 | type=click.STRING, 29 | required=False, 30 | ) 31 | @click.option( 32 | "-w", 33 | "--workers", 34 | help="The number of worker processes for handling requests.", 35 | type=click.IntRange(min=1, max=cpu_count()), 36 | required=False, 37 | ) 38 | @click.option( 39 | "-D", 40 | "--daemon", 41 | help="Daemonize the Gunicorn process.", 42 | is_flag=True, 43 | required=False, 44 | ) 45 | @click.option( 46 | "-e", 47 | "--env", 48 | "raw_env", 49 | help="Set environment variables in the execution environment.", 50 | type=click.STRING, 51 | multiple=True, 52 | required=False, 53 | ) 54 | @click.option( 55 | "--pid", 56 | "pidfile", 57 | help="Specifies the PID file.", 58 | type=click.Path(), 59 | callback=validate_directory, 60 | required=False, 61 | ) 62 | @click.pass_context 63 | def serve(ctx: click.Context, **options: Dict[str, Any]) -> None: 64 | """Define command-line interface serve command. 65 | 66 | Args: 67 | ctx (click.Context): Click Context class object instance. 68 | options (typing.Dict[str, typing.Any]): Map of command option names to 69 | their parsed values. 70 | 71 | """ 72 | overrides = dict() 73 | 74 | for key, value in options.items(): 75 | source = ctx.get_parameter_source(key) 76 | if source and source.name == "COMMANDLINE": 77 | overrides[key] = value 78 | 79 | ApplicationLoader( 80 | application=get_application(), 81 | overrides=overrides, 82 | ).run() 83 | -------------------------------------------------------------------------------- /example/cli/utils.py: -------------------------------------------------------------------------------- 1 | """Command-line interface - utilities.""" 2 | import os 3 | 4 | import click 5 | 6 | 7 | def validate_directory(ctx: click.Context, param: click.Option, value: str) -> str: 8 | """Verify if given path value is writable and parent directory exists. 9 | 10 | Args: 11 | ctx (click.Context): Click Context object instance. 12 | param (click.Option): Click Option object instance. 13 | value (str): Click Option value. 14 | 15 | Returns: 16 | str: Original value. 17 | 18 | Raises: 19 | click.BadParameter: If given path value is not writable or parent 20 | directory does not exist. 21 | 22 | """ 23 | if not param.required and not value: 24 | return value 25 | 26 | dirname = os.path.dirname(value) 27 | 28 | if not os.path.exists(dirname): 29 | raise click.BadParameter(f"Directory '{dirname}' does not exist.") 30 | elif not os.access(dirname, os.W_OK): 31 | raise click.BadParameter(f"Directory '{dirname}' is not writable.") 32 | 33 | return value 34 | -------------------------------------------------------------------------------- /example/config/__init__.py: -------------------------------------------------------------------------------- 1 | """Application configuration. 2 | 3 | The ``config`` submodule defines configuration for your application, router, 4 | gunicorn, and more. 5 | 6 | Resources: 7 | 1. `Pydantic documentation`_ 8 | 2. `Gunicorn documentation`_ 9 | 10 | .. _Pydantic documentation: 11 | https://pydantic-docs.helpmanual.io/ 12 | 13 | .. _Gunicorn documentation: 14 | https://docs.gunicorn.org/en/20.1.0/ 15 | 16 | """ 17 | from example.config.application import settings 18 | from example.config.redis import redis 19 | 20 | 21 | __all__ = ("settings", "redis") 22 | -------------------------------------------------------------------------------- /example/config/application.py: -------------------------------------------------------------------------------- 1 | """Application configuration - FastAPI.""" 2 | from pydantic import BaseSettings 3 | from example.version import __version__ 4 | 5 | 6 | class Application(BaseSettings): 7 | """Define application configuration model. 8 | 9 | Constructor will attempt to determine the values of any fields not passed 10 | as keyword arguments by reading from the environment. Default values will 11 | still be used if the matching environment variable is not set. 12 | 13 | Environment variables: 14 | * FASTAPI_DEBUG 15 | * FASTAPI_PROJECT_NAME 16 | * FASTAPI_VERSION 17 | * FASTAPI_DOCS_URL 18 | * FASTAPI_USE_REDIS 19 | 20 | Attributes: 21 | DEBUG (bool): FastAPI logging level. You should disable this for 22 | production. 23 | PROJECT_NAME (str): FastAPI project name. 24 | VERSION (str): Application version. 25 | DOCS_URL (str): Path where swagger ui will be served at. 26 | USE_REDIS (bool): Whether or not to use Redis. 27 | 28 | """ 29 | 30 | DEBUG: bool = True 31 | PROJECT_NAME: str = "example" 32 | VERSION: str = __version__ 33 | DOCS_URL: str = "/" 34 | USE_REDIS: bool = False 35 | # All your additional application configuration should go either here or in 36 | # separate file in this submodule. 37 | 38 | class Config: 39 | """Config sub-class needed to customize BaseSettings settings. 40 | 41 | Attributes: 42 | case_sensitive (bool): When case_sensitive is True, the environment 43 | variable names must match field names (optionally with a prefix) 44 | env_prefix (str): The prefix for environment variable. 45 | 46 | Resources: 47 | https://pydantic-docs.helpmanual.io/usage/settings/ 48 | 49 | """ 50 | 51 | case_sensitive = True 52 | env_prefix = "FASTAPI_" 53 | 54 | 55 | settings = Application() 56 | -------------------------------------------------------------------------------- /example/config/gunicorn.py: -------------------------------------------------------------------------------- 1 | """Gunicorn configuration file. 2 | 3 | Resources: 4 | 1. https://docs.gunicorn.org/en/20.1.0/settings.html 5 | 6 | """ 7 | import os 8 | 9 | 10 | # Server socket 11 | # 12 | # bind - The socket to bind. 13 | # 14 | # A string of the form: 'HOST', 'HOST:PORT', 'unix:PATH'. 15 | # An IP is a valid HOST. 16 | # 17 | # backlog - The number of pending connections. This refers 18 | # to the number of clients that can be waiting to be 19 | # served. Exceeding this number results in the client 20 | # getting an error when attempting to connect. It should 21 | # only affect servers under significant load. 22 | # 23 | # Must be a positive integer. Generally set in the 64-2048 24 | # range. 25 | # 26 | 27 | bind = os.getenv("FASTAPI_BIND", "127.0.0.1:8000") 28 | backlog = 2048 29 | 30 | # 31 | # Worker processes 32 | # 33 | # workers - The number of worker processes that this server 34 | # should keep alive for handling requests. 35 | # 36 | # A positive integer generally in the 2-4 x $(NUM_CORES) 37 | # range. You'll want to vary this a bit to find the best 38 | # for your particular application's work load. 39 | # 40 | # worker_class - The type of workers to use. The default 41 | # sync class should handle most 'normal' types of work 42 | # loads. You'll want to read 43 | # http://docs.gunicorn.org/en/latest/design.html#choosing-a-worker-type 44 | # for information on when you might want to choose one 45 | # of the other worker classes. 46 | # 47 | # A string referring to a Python path to a subclass of 48 | # gunicorn.workers.base.Worker. The default provided values 49 | # can be seen at 50 | # http://docs.gunicorn.org/en/latest/settings.html#worker-class 51 | # 52 | # worker_connections - For the eventlet and gevent worker classes 53 | # this limits the maximum number of simultaneous clients that 54 | # a single process can handle. 55 | # 56 | # A positive integer generally set to around 1000. 57 | # 58 | # timeout - If a worker does not notify the master process in this 59 | # number of seconds it is killed and a new worker is spawned 60 | # to replace it. 61 | # 62 | # Generally set to thirty seconds. Only set this noticeably 63 | # higher if you're sure of the repercussions for sync workers. 64 | # For the non sync workers it just means that the worker 65 | # process is still communicating and is not tied to the length 66 | # of time required to handle a single request. 67 | # 68 | # keepalive - The number of seconds to wait for the next request 69 | # on a Keep-Alive HTTP connection. 70 | # 71 | # A positive integer. Generally set in the 1-5 seconds range. 72 | # 73 | # reload - Restart workers when code changes. 74 | # 75 | # True or False 76 | 77 | workers = int(os.getenv("FASTAPI_WORKERS", 2)) 78 | worker_class = "uvicorn.workers.UvicornWorker" 79 | worker_connections = 1000 80 | timeout = 30 81 | keepalive = 2 82 | reload = False 83 | 84 | # 85 | # spew - Install a trace function that spews every line of Python 86 | # that is executed when running the server. This is the 87 | # nuclear option. 88 | # 89 | # True or False 90 | # 91 | 92 | spew = False 93 | 94 | # 95 | # Server mechanics 96 | # 97 | # daemon - Detach the main Gunicorn process from the controlling 98 | # terminal with a standard fork/fork sequence. 99 | # 100 | # True or False 101 | # 102 | # raw_env - Pass environment variables to the execution environment. 103 | # 104 | # pidfile - The path to a pid file to write 105 | # 106 | # A path string or None to not write a pid file. 107 | # 108 | # user - Switch worker processes to run as this user. 109 | # 110 | # A valid user id (as an integer) or the name of a user that 111 | # can be retrieved with a call to pwd.getpwnam(value) or None 112 | # to not change the worker process user. 113 | # 114 | # group - Switch worker process to run as this group. 115 | # 116 | # A valid group id (as an integer) or the name of a user that 117 | # can be retrieved with a call to pwd.getgrnam(value) or None 118 | # to change the worker processes group. 119 | # 120 | # umask - A mask for file permissions written by Gunicorn. Note that 121 | # this affects unix socket permissions. 122 | # 123 | # A valid value for the os.umask(mode) call or a string 124 | # compatible with int(value, 0) (0 means Python guesses 125 | # the base, so values like "0", "0xFF", "0022" are valid 126 | # for decimal, hex, and octal representations) 127 | # 128 | # tmp_upload_dir - A directory to store temporary request data when 129 | # requests are read. This will most likely be disappearing soon. 130 | # 131 | # A path to a directory where the process owner can write. Or 132 | # None to signal that Python should choose one on its own. 133 | # 134 | 135 | daemon = False 136 | # raw_env = [ 137 | # 'DJANGO_SECRET_KEY=something', 138 | # 'SPAM=eggs', 139 | # ] 140 | pidfile = None 141 | umask = 0 142 | user = None 143 | group = None 144 | tmp_upload_dir = None 145 | 146 | # 147 | # Logging 148 | # 149 | # logfile - The path to a log file to write to. 150 | # 151 | # A path string. "-" means log to stdout. 152 | # 153 | # loglevel - The granularity of log output 154 | # 155 | # A string of "debug", "info", "warning", "error", "critical" 156 | # 157 | 158 | errorlog = "-" 159 | loglevel = os.getenv("FASTAPI_GUNICORN_LOG_LEVEL", "info") 160 | accesslog = "-" 161 | access_log_format = os.getenv( 162 | "FASTAPI_GUNICORN_LOG_FORMAT", 163 | '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"', 164 | ) 165 | 166 | # 167 | # Process naming 168 | # 169 | # proc_name - A base to use with setproctitle to change the way 170 | # that Gunicorn processes are reported in the system process 171 | # table. This affects things like 'ps' and 'top'. If you're 172 | # going to be running more than one instance of Gunicorn you'll 173 | # probably want to set a name to tell them apart. This requires 174 | # that you install the setproctitle module. 175 | # 176 | # A string or None to choose a default of something like 'gunicorn'. 177 | # 178 | 179 | proc_name = None 180 | 181 | # 182 | # Server hooks 183 | # 184 | # post_fork - Called just after a worker has been forked. 185 | # 186 | # A callable that takes a server and worker instance 187 | # as arguments. 188 | # 189 | # pre_fork - Called just prior to forking the worker subprocess. 190 | # 191 | # A callable that accepts the same arguments as after_fork 192 | # 193 | # pre_exec - Called just prior to forking off a secondary 194 | # master process during things like config reloading. 195 | # 196 | # A callable that takes a server instance as the sole argument. 197 | # 198 | 199 | 200 | def post_fork(server, worker): 201 | """Execute after a worker is forked.""" 202 | server.log.info("Worker spawned (pid: %s)", worker.pid) 203 | 204 | 205 | def pre_fork(server, worker): 206 | """Execute before a worker is forked.""" 207 | pass 208 | 209 | 210 | def pre_exec(server): 211 | """Execute before a new master process is forked.""" 212 | server.log.info("Forked child, re-executing.") 213 | 214 | 215 | def when_ready(server): 216 | """Execute just after the server is started.""" 217 | server.log.info("Server is ready. Spawning workers") 218 | 219 | 220 | def worker_int(worker): 221 | """Execute just after a worker exited on SIGINT or SIGQUIT.""" 222 | worker.log.info("worker received INT or QUIT signal") 223 | 224 | # get traceback info 225 | import threading 226 | import sys 227 | import traceback 228 | 229 | id2name = {th.ident: th.name for th in threading.enumerate()} 230 | code = [] 231 | 232 | for threadId, stack in sys._current_frames().items(): 233 | code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId)) 234 | for filename, lineno, name, line in traceback.extract_stack(stack): 235 | code.append('File: "%s", line %d, in %s' % (filename, lineno, name)) 236 | if line: 237 | code.append(" %s" % (line.strip())) 238 | 239 | worker.log.debug("\n".join(code)) 240 | 241 | 242 | def worker_abort(worker): 243 | """Execute when worker received the SIGABRT signal.""" 244 | worker.log.info("worker received SIGABRT signal") 245 | -------------------------------------------------------------------------------- /example/config/redis.py: -------------------------------------------------------------------------------- 1 | """Application configuration - Redis.""" 2 | from typing import Optional 3 | 4 | from pydantic import BaseSettings 5 | 6 | 7 | class Redis(BaseSettings): 8 | """Define Redis configuration model. 9 | 10 | Constructor will attempt to determine the values of any fields not passed 11 | as keyword arguments by reading from the environment. Default values will 12 | still be used if the matching environment variable is not set. 13 | 14 | Environment variables: 15 | * FASTAPI_REDIS_HOTS 16 | * FASTAPI_REDIS_PORT 17 | * FASTAPI_REDIS_USERNAME 18 | * FASTAPI_REDIS_PASSWORD 19 | * FASTAPI_REDIS_USE_SENTINEL 20 | 21 | Attributes: 22 | REDIS_HOTS (str): Redis host. 23 | REDIS_PORT (int): Redis port. 24 | REDIS_USERNAME (typing.Optional[str]): Redis username. 25 | REDIS_PASSWORD (typing.Optional[str]): Redis password. 26 | REDIS_USE_SENTINEL (bool): If provided Redis config is for Sentinel. 27 | 28 | """ 29 | 30 | REDIS_HOST: str = "127.0.0.1" 31 | REDIS_PORT: int = 6379 32 | REDIS_USERNAME: Optional[str] = None 33 | REDIS_PASSWORD: Optional[str] = None 34 | REDIS_USE_SENTINEL: bool = False 35 | 36 | class Config: 37 | """Config sub-class needed to customize BaseSettings settings. 38 | 39 | Attributes: 40 | case_sensitive (bool): When case_sensitive is True, the environment 41 | variable names must match field names (optionally with a prefix) 42 | env_prefix (str): The prefix for environment variable. 43 | 44 | Resources: 45 | https://pydantic-docs.helpmanual.io/usage/settings/ 46 | 47 | """ 48 | 49 | case_sensitive = True 50 | env_prefix = "FASTAPI_" 51 | 52 | 53 | redis = Redis() 54 | -------------------------------------------------------------------------------- /example/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/example/py.typed -------------------------------------------------------------------------------- /example/version.py: -------------------------------------------------------------------------------- 1 | """example version.""" 2 | __version__ = "0.1.0" 3 | -------------------------------------------------------------------------------- /example/wsgi.py: -------------------------------------------------------------------------------- 1 | """Application Web Server Gateway Interface - gunicorn.""" 2 | from typing import Any, Optional, Dict 3 | import logging 4 | 5 | from gunicorn.app.base import BaseApplication 6 | from example.config import gunicorn 7 | 8 | 9 | class ApplicationLoader(BaseApplication): # type: ignore 10 | """Define gunicorn interface for any given web framework. 11 | 12 | Args: 13 | application (typing.Any): Any given web framework application object 14 | instance. 15 | overrides (typing.Optional[typing.Dict[str, typing.Any]]): Map of 16 | gunicorn settings to override. 17 | 18 | Attributes: 19 | _application (typing.Any): Any given web framework application object 20 | instance. 21 | _overrides (typing.Optional[typing.Dict[str, typing.Any]]): Map of 22 | gunicorn settings to override. 23 | 24 | """ 25 | 26 | def __init__(self, application: Any, overrides: Optional[Dict[str, Any]] = None): 27 | """Initialize ApplicationLoader class object instance.""" 28 | if not overrides: 29 | overrides = dict() 30 | 31 | self._overrides = overrides 32 | self._application = application 33 | super().__init__() 34 | 35 | def _set_cfg(self, cfg: Dict[str, Any]) -> None: 36 | """Set gunicorn config given map of setting names to their values. 37 | 38 | Args: 39 | cfg (typing.Dict[str, typing.Any]): Map of gunicorn setting names to 40 | their values. 41 | 42 | Raises: 43 | Exception: Raised on config error. 44 | 45 | """ 46 | for k, v in cfg.items(): 47 | # Ignore unknown names 48 | if k not in self.cfg.settings: 49 | continue 50 | 51 | try: 52 | self.cfg.set(k.lower(), v) 53 | except Exception as ex: 54 | self.logger.error(f"Invalid value for {k}: {v}") 55 | raise ex 56 | 57 | def load_config(self) -> None: 58 | """Load gunicorn configuration.""" 59 | self.logger = logging.getLogger(self.__class__.__name__) 60 | self.cfg.set("default_proc_name", "example") 61 | 62 | cfg = vars(gunicorn) 63 | cfg.update(self._overrides) 64 | 65 | self._set_cfg(cfg) 66 | 67 | def init(self, parser: Any, opts: Any, args: Any) -> None: 68 | """Patch required but not needed base class method.""" 69 | pass 70 | 71 | def load(self) -> Any: 72 | """Load WSGI application.""" 73 | return self._application 74 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1679737941, 9 | "narHash": "sha256-srSD9CwsVPnUMsIZ7Kt/UegkKUEBcTyU1Rev7mO45S0=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "3502ee99d6dade045bdeaf7b0cd8ec703484c25c", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "flake-utils": { 22 | "locked": { 23 | "lastModified": 1667395993, 24 | "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1687829761, 39 | "narHash": "sha256-QRe1Y8SS3M4GeC58F/6ajz6V0ZLUVWX3ZAMgov2N3/g=", 40 | "owner": "NixOS", 41 | "repo": "nixpkgs", 42 | "rev": "9790f3242da2152d5aa1976e3e4b8b414f4dd206", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "NixOS", 47 | "ref": "nixos-23.05", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "nixpkgs-lib": { 53 | "locked": { 54 | "dir": "lib", 55 | "lastModified": 1678375444, 56 | "narHash": "sha256-XIgHfGvjFvZQ8hrkfocanCDxMefc/77rXeHvYdzBMc8=", 57 | "owner": "NixOS", 58 | "repo": "nixpkgs", 59 | "rev": "130fa0baaa2b93ec45523fdcde942f6844ee9f6e", 60 | "type": "github" 61 | }, 62 | "original": { 63 | "dir": "lib", 64 | "owner": "NixOS", 65 | "ref": "nixos-unstable", 66 | "repo": "nixpkgs", 67 | "type": "github" 68 | } 69 | }, 70 | "poetry2nix": { 71 | "inputs": { 72 | "flake-utils": "flake-utils", 73 | "nixpkgs": [ 74 | "nixpkgs" 75 | ] 76 | }, 77 | "locked": { 78 | "lastModified": 1687996283, 79 | "narHash": "sha256-JD4S39vMhn0KcAhCvvrlcLP2jonwDsuMzdgC6S92LEg=", 80 | "owner": "nix-community", 81 | "repo": "poetry2nix", 82 | "rev": "76393d08880ee0a187922bd213aaafd0df809be5", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "nix-community", 87 | "ref": "1.42.0", 88 | "repo": "poetry2nix", 89 | "type": "github" 90 | } 91 | }, 92 | "root": { 93 | "inputs": { 94 | "flake-parts": "flake-parts", 95 | "nixpkgs": "nixpkgs", 96 | "poetry2nix": "poetry2nix" 97 | } 98 | } 99 | }, 100 | "root": "root", 101 | "version": 7 102 | } -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "example flake"; 3 | nixConfig = { 4 | bash-prompt = ''\n\[\033[1;32m\][nix-develop:\w]\$\[\033[0m\] ''; 5 | extra-trusted-public-keys = [ 6 | "fastapi-mvc.cachix.org-1:knQ8Qo41bnhBmOB6Sp0UH10EV76AXW5o69SbAS668Fg=" 7 | ]; 8 | extra-substituters = [ 9 | "https://fastapi-mvc.cachix.org" 10 | ]; 11 | }; 12 | 13 | inputs = { 14 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05"; 15 | flake-parts.url = "github:hercules-ci/flake-parts"; 16 | poetry2nix = { 17 | url = "github:nix-community/poetry2nix?ref=1.42.0"; 18 | inputs.nixpkgs.follows = "nixpkgs"; 19 | }; 20 | }; 21 | 22 | outputs = { self, nixpkgs, flake-parts, poetry2nix }@inputs: 23 | let 24 | mkApp = 25 | { drv 26 | , name ? drv.pname or drv.name 27 | , exePath ? drv.passthru.exePath or "/bin/${name}" 28 | }: 29 | { 30 | type = "app"; 31 | program = "${drv}${exePath}"; 32 | }; 33 | in 34 | flake-parts.lib.mkFlake { inherit inputs; } { 35 | imports = [ 36 | inputs.flake-parts.flakeModules.easyOverlay 37 | ]; 38 | systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 39 | perSystem = { config, self', inputs', pkgs, system, ... }: { 40 | # Add poetry2nix overrides to nixpkgs 41 | _module.args.pkgs = import nixpkgs { 42 | inherit system; 43 | overlays = [ self.overlays.poetry2nix ]; 44 | }; 45 | 46 | packages = 47 | let 48 | mkProject = 49 | { python ? pkgs.python3 50 | }: 51 | pkgs.callPackage ./default.nix { 52 | inherit python; 53 | poetry2nix = pkgs.poetry2nix; 54 | }; 55 | in 56 | { 57 | default = mkProject { }; 58 | example-py38 = mkProject { python = pkgs.python38; }; 59 | example-py39 = mkProject { python = pkgs.python39; }; 60 | example-py310 = mkProject { python = pkgs.python310; }; 61 | example-py311 = mkProject { python = pkgs.python311; }; 62 | example-dev = pkgs.callPackage ./editable.nix { 63 | poetry2nix = pkgs.poetry2nix; 64 | python = pkgs.python3; 65 | }; 66 | } // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { 67 | image = pkgs.callPackage ./image.nix { 68 | inherit pkgs; 69 | app = config.packages.default; 70 | }; 71 | }; 72 | 73 | overlayAttrs = { 74 | inherit (config.packages) default; 75 | }; 76 | 77 | apps = { 78 | example = mkApp { drv = config.packages; }; 79 | metrics = { 80 | type = "app"; 81 | program = toString (pkgs.writeScript "metrics" '' 82 | export PATH="${pkgs.lib.makeBinPath [ 83 | config.packages.example-dev 84 | pkgs.git 85 | ]}" 86 | echo "[nix][metrics] Run example PEP 8 checks." 87 | flake8 --select=E,W,I --max-line-length 88 --import-order-style pep8 --statistics --count example 88 | echo "[nix][metrics] Run example PEP 257 checks." 89 | flake8 --select=D --ignore D301 --statistics --count example 90 | echo "[nix][metrics] Run example pyflakes checks." 91 | flake8 --select=F --statistics --count example 92 | echo "[nix][metrics] Run example code complexity checks." 93 | flake8 --select=C901 --statistics --count example 94 | echo "[nix][metrics] Run example open TODO checks." 95 | flake8 --select=T --statistics --count example tests 96 | echo "[nix][metrics] Run example black checks." 97 | black --check example 98 | ''); 99 | }; 100 | docs = { 101 | type = "app"; 102 | program = toString (pkgs.writeScript "docs" '' 103 | export PATH="${pkgs.lib.makeBinPath [ 104 | config.packages.example-dev 105 | pkgs.git 106 | ]}" 107 | echo "[nix][docs] Build example documentation." 108 | sphinx-build docs site 109 | ''); 110 | }; 111 | unit-test = { 112 | type = "app"; 113 | program = toString (pkgs.writeScript "unit-test" '' 114 | export PATH="${pkgs.lib.makeBinPath [ 115 | config.packages.example-dev 116 | pkgs.git 117 | ]}" 118 | echo "[nix][unit-test] Run example unit tests." 119 | pytest tests/unit 120 | ''); 121 | }; 122 | integration-test = { 123 | type = "app"; 124 | program = toString (pkgs.writeScript "integration-test" '' 125 | export PATH="${pkgs.lib.makeBinPath [ 126 | config.packages.example-dev 127 | pkgs.git 128 | pkgs.coreutils 129 | ]}" 130 | echo "[nix][integration-test] Run example unit tests." 131 | pytest tests/integration 132 | ''); 133 | }; 134 | coverage = { 135 | type = "app"; 136 | program = toString (pkgs.writeScript "coverage" '' 137 | export PATH="${pkgs.lib.makeBinPath [ 138 | config.packages.example-dev 139 | pkgs.git 140 | pkgs.coreutils 141 | ]}" 142 | echo "[nix][coverage] Run example tests coverage." 143 | pytest --cov=example --cov-fail-under=90 --cov-report=xml --cov-report=term-missing tests 144 | ''); 145 | }; 146 | mypy = { 147 | type = "app"; 148 | program = toString (pkgs.writeScript "mypy" '' 149 | export PATH="${pkgs.lib.makeBinPath [ 150 | config.packages.example-dev 151 | pkgs.git 152 | ]}" 153 | echo "[nix][mypy] Run example mypy checks." 154 | mypy example 155 | ''); 156 | }; 157 | test = { 158 | type = "app"; 159 | program = toString (pkgs.writeScript "test" '' 160 | ${config.apps.unit-test.program} 161 | ${config.apps.integration-test.program} 162 | ''); 163 | }; 164 | }; 165 | 166 | devShells = { 167 | default = config.packages.example-dev.env.overrideAttrs (oldAttrs: { 168 | buildInputs = [ 169 | pkgs.git 170 | pkgs.poetry 171 | ]; 172 | }); 173 | poetry = import ./shell.nix { inherit pkgs; }; 174 | }; 175 | }; 176 | flake = { 177 | overlays.poetry2nix = nixpkgs.lib.composeManyExtensions [ 178 | poetry2nix.overlay 179 | (import ./overlay.nix) 180 | ]; 181 | }; 182 | }; 183 | } 184 | -------------------------------------------------------------------------------- /image.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } 2 | , app 3 | , name ? "example" 4 | , tag ? "latest" 5 | }: 6 | 7 | pkgs.dockerTools.buildImage { 8 | inherit name tag; 9 | 10 | copyToRoot = pkgs.buildEnv { 11 | name = "image-root"; 12 | paths = [ 13 | app 14 | pkgs.cacert 15 | pkgs.tzdata 16 | ]; 17 | pathsToLink = [ "/bin" ]; 18 | }; 19 | 20 | runAsRoot = '' 21 | #!${pkgs.runtimeShell} 22 | ${pkgs.dockerTools.shadowSetup} 23 | mkdir /tmp 24 | chmod 777 -R /tmp 25 | groupadd -r nonroot 26 | useradd -r -g nonroot nonroot 27 | mkdir -p /workspace 28 | chown nonroot:nonroot /workspace 29 | ''; 30 | 31 | config = { 32 | Env = [ 33 | "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" 34 | "PYTHONDONTWRITEBYTECODE=1" 35 | "PYTHONUNBUFFERED=1" 36 | ]; 37 | User = "nonroot"; 38 | WorkingDir = "/workspace"; 39 | Entrypoint = [ "${app}/bin/example" ]; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /manifests/all-redis-operator-resources.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: redisoperator 6 | name: redisoperator 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: redisoperator 12 | strategy: 13 | type: RollingUpdate 14 | template: 15 | metadata: 16 | labels: 17 | app: redisoperator 18 | spec: 19 | serviceAccountName: redisoperator 20 | containers: 21 | - image: quay.io/spotahome/redis-operator:v1.0.0 22 | imagePullPolicy: IfNotPresent 23 | name: app 24 | securityContext: 25 | readOnlyRootFilesystem: true 26 | runAsNonRoot: true 27 | runAsUser: 1000 28 | resources: 29 | limits: 30 | cpu: 100m 31 | memory: 50Mi 32 | requests: 33 | cpu: 10m 34 | memory: 50Mi 35 | restartPolicy: Always 36 | --- 37 | apiVersion: rbac.authorization.k8s.io/v1 38 | kind: ClusterRoleBinding 39 | metadata: 40 | name: redisoperator 41 | roleRef: 42 | apiGroup: rbac.authorization.k8s.io 43 | kind: ClusterRole 44 | name: redisoperator 45 | subjects: 46 | - kind: ServiceAccount 47 | name: redisoperator 48 | namespace: default 49 | --- 50 | apiVersion: rbac.authorization.k8s.io/v1 51 | kind: ClusterRole 52 | metadata: 53 | name: redisoperator 54 | rules: 55 | - apiGroups: 56 | - databases.spotahome.com 57 | resources: 58 | - redisfailovers 59 | verbs: 60 | - "*" 61 | - apiGroups: 62 | - apiextensions.k8s.io 63 | resources: 64 | - customresourcedefinitions 65 | verbs: 66 | - "*" 67 | - apiGroups: 68 | - "" 69 | resources: 70 | - pods 71 | - services 72 | - endpoints 73 | - events 74 | - configmaps 75 | verbs: 76 | - "*" 77 | - apiGroups: 78 | - "" 79 | resources: 80 | - secrets 81 | verbs: 82 | - "get" 83 | - apiGroups: 84 | - apps 85 | resources: 86 | - deployments 87 | - statefulsets 88 | verbs: 89 | - "*" 90 | - apiGroups: 91 | - policy 92 | resources: 93 | - poddisruptionbudgets 94 | verbs: 95 | - "*" 96 | --- 97 | apiVersion: v1 98 | kind: ServiceAccount 99 | metadata: 100 | name: redisoperator -------------------------------------------------------------------------------- /manifests/persistent-storage-no-pvc-deletion.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: databases.spotahome.com/v1 2 | kind: RedisFailover 3 | metadata: 4 | name: redisfailover-persistent-keep 5 | spec: 6 | redis: 7 | image: redis:6.2.4-alpine 8 | imagePullPolicy: IfNotPresent 9 | replicas: 3 10 | storage: 11 | keepAfterDeletion: true 12 | persistentVolumeClaim: 13 | metadata: 14 | name: redisfailover-persistent-keep-data 15 | spec: 16 | accessModes: 17 | - ReadWriteOnce 18 | resources: 19 | requests: 20 | storage: 1Gi 21 | sentinel: 22 | image: redis:6.2.4-alpine 23 | imagePullPolicy: IfNotPresent 24 | replicas: 3 25 | -------------------------------------------------------------------------------- /overlay.nix: -------------------------------------------------------------------------------- 1 | final: prev: { 2 | # p2n-final & p2n-prev refers to poetry2nix 3 | poetry2nix = prev.poetry2nix.overrideScope' (p2n-final: p2n-prev: { 4 | 5 | # py-final & py-prev refers to python packages 6 | defaultPoetryOverrides = p2n-prev.defaultPoetryOverrides.extend (py-final: py-prev: { 7 | 8 | sphinx = py-prev.sphinx.overridePythonAttrs (old: { 9 | buildInputs = old.buildInputs or [ ] ++ [ py-final.flit-core ]; 10 | }); 11 | 12 | pydantic = py-prev.pydantic.overrideAttrs (old: { 13 | buildInputs = old.buildInputs or [ ] ++ [ final.libxcrypt ]; 14 | }); 15 | 16 | flake8-todo = py-prev.flake8-todo.overridePythonAttrs (old: { 17 | buildInputs = old.buildInputs or [ ] ++ [ py-final.setuptools ]; 18 | }); 19 | 20 | pathspec = py-prev.pathspec.overridePythonAttrs (old: { 21 | buildInputs = old.buildInputs or [ ] ++ [ py-final.flit-core ]; 22 | }); 23 | 24 | pydocstyle = py-prev.pydocstyle.overridePythonAttrs (old: { 25 | buildInputs = old.buildInputs or [ ] ++ [ py-final.poetry-core py-final.setuptools ]; 26 | }); 27 | 28 | iniconfig = py-prev.iniconfig.overridePythonAttrs (old: { 29 | buildInputs = old.buildInputs or [ ] ++ [ 30 | py-final.hatch-vcs 31 | py-final.hatchling 32 | py-final.build 33 | py-final.setuptools-scm 34 | ]; 35 | }); 36 | 37 | plumbum = py-prev.plumbum.overridePythonAttrs (old: { 38 | buildInputs = old.buildInputs or [ ] ++ [ py-final.hatch-vcs py-final.hatchling ]; 39 | }); 40 | 41 | pyyaml-include = py-prev.pyyaml-include.overridePythonAttrs (old: { 42 | postPatch = '' 43 | substituteInPlace setup.py --replace 'setup()' 'setup(version="${old.version}")' 44 | ''; 45 | }); 46 | 47 | fastapi-mvc = py-prev.fastapi-mvc.overridePythonAttrs (old: { 48 | buildInputs = old.buildInputs or [ ] ++ [ py-final.poetry ]; 49 | }); 50 | 51 | }); 52 | 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "example" 3 | version = "0.1.0" 4 | description = "This project was generated with fastapi-mvc." 5 | authors = ["Radosław Szamszur "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/fastapi-mvc/example" 9 | classifiers = [ 10 | "Intended Audience :: Developers", 11 | "Natural Language :: English", 12 | "Programming Language :: Python :: 3", 13 | "Programming Language :: Python :: 3.8", 14 | "Programming Language :: Python :: 3.9", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | ] 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.8" 21 | fastapi = "~0.98.0" 22 | uvicorn = {extras = ["standard"], version = "~0.22.0"} 23 | gunicorn = "~20.1.0" 24 | click = "~8.1.3" 25 | redis = "~4.5.5" 26 | aiohttp = "~3.8.4" 27 | 28 | [tool.poetry.group.dev.dependencies] 29 | pytest = "~7.4.0" 30 | pytest-cov = "~4.0.0" 31 | pytest-asyncio = "~0.21.0" 32 | requests = "~2.28.2" 33 | httpx = "~0.23.3" 34 | aioresponses = "~0.7.3" 35 | mypy = "~1.4.1" 36 | flake8 = "~5.0.4" 37 | flake8-docstrings = "~1.7.0" 38 | flake8-import-order = "~0.18.1" 39 | flake8-todo = "^0.7" 40 | black = "~23.3.0" 41 | Sphinx = "~5.3.0" 42 | Pallets-Sphinx-Themes = "~2.0.2" 43 | myst-parser = "~1.0.0" 44 | fastapi-mvc = "^0.26.0" 45 | 46 | [tool.poetry.scripts] 47 | example = 'example.cli:cli' 48 | 49 | [tool.poetry.urls] 50 | "Issues" = "https://github.com/fastapi-mvc/example/issues" 51 | 52 | [build-system] 53 | requires = ["poetry-core>=1.0.0"] 54 | build-backend = "poetry.core.masonry.api" 55 | 56 | [tool.coverage.run] 57 | omit = [ 58 | "example/config/gunicorn.py", 59 | "example/__main__.py", 60 | ] 61 | 62 | [tool.coverage.report] 63 | exclude_lines = [ 64 | "pass", 65 | ] 66 | 67 | [tool.mypy] 68 | exclude = [ 69 | "config/gunicorn.py" 70 | ] 71 | plugins = "pydantic.mypy" 72 | python_version = '3.10' 73 | show_error_codes = true 74 | follow_imports = 'silent' 75 | strict_optional = true 76 | warn_redundant_casts = true 77 | warn_unused_ignores = true 78 | disallow_any_generics = true 79 | check_untyped_defs = true 80 | no_implicit_reexport = true 81 | warn_unused_configs = true 82 | disallow_subclassing_any = true 83 | disallow_incomplete_defs = true 84 | disallow_untyped_decorators = true 85 | disallow_untyped_calls = true 86 | disallow_untyped_defs = true 87 | implicit_optional = true 88 | 89 | [[tool.mypy.overrides]] 90 | module = [ 91 | "gunicorn.*", 92 | "redis.*", 93 | ] 94 | ignore_missing_imports = true 95 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } 2 | , python ? "python3" 3 | }: 4 | 5 | let 6 | pythonPackage = builtins.getAttr (python) pkgs; 7 | poetry = pkgs.poetry.override { python = pythonPackage; }; 8 | in 9 | pkgs.mkShell { 10 | buildInputs = [ 11 | pkgs.gnumake 12 | pkgs.curl 13 | pythonPackage 14 | poetry 15 | ]; 16 | shellHook = '' 17 | export POETRY_HOME=${poetry} 18 | export POETRY_BINARY=${poetry}/bin/poetry 19 | export POETRY_VIRTUALENVS_IN_PROJECT=true 20 | unset SOURCE_DATE_EPOCH 21 | ''; 22 | } 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/integration/app/__init__.py -------------------------------------------------------------------------------- /tests/integration/app/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/integration/app/controllers/__init__.py -------------------------------------------------------------------------------- /tests/integration/app/controllers/test_ready.py: -------------------------------------------------------------------------------- 1 | from example.config import settings 2 | 3 | 4 | class TestReadyController: 5 | 6 | def test_should_return_ok(self, app_runner): 7 | # given 8 | settings.USE_REDIS = False 9 | 10 | # when 11 | response = app_runner.get("/api/ready") 12 | 13 | # then 14 | assert response.status_code == 200 15 | assert response.json() == {"status": "ok"} 16 | 17 | def test_should_return_not_found_when_invalid_uri(self, app_runner): 18 | # given / when 19 | response = app_runner.get("/api/ready/123") 20 | 21 | # then 22 | assert response.status_code == 404 23 | 24 | def test_should_return_bad_gateway_when_redis_unavailable(self, app_runner): 25 | # given 26 | settings.USE_REDIS = True 27 | 28 | # when 29 | response = app_runner.get("/api/ready") 30 | 31 | # then 32 | assert response.status_code == 502 33 | assert response.json() == { 34 | "error": { 35 | "code": 502, 36 | "message": "Could not connect to Redis", 37 | "status": "BAD_GATEWAY", 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | from example.app import get_application 4 | from example.config import settings 5 | 6 | 7 | @pytest.fixture 8 | def app_runner(): 9 | # Overriding to true in order to initialize redis client on FastAPI event 10 | # startup handler. It'll be needed for unit tests. 11 | settings.USE_REDIS = True 12 | app = get_application() 13 | 14 | with TestClient(app) as client: 15 | yield client 16 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/app/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | from example.app import get_application 4 | from example.config import settings 5 | 6 | 7 | @pytest.fixture 8 | def app_runner(): 9 | # Overriding to true in order to initialize redis client on FastAPI event 10 | # startup handler. It'll be needed for unit tests. 11 | settings.USE_REDIS = True 12 | app = get_application() 13 | 14 | with TestClient(app) as client: 15 | yield client 16 | -------------------------------------------------------------------------------- /tests/unit/app/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/app/controllers/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/controllers/test_ready.py: -------------------------------------------------------------------------------- 1 | from example.config import settings 2 | 3 | 4 | class TestReadyController: 5 | 6 | def test_should_return_ok(self, app_runner): 7 | # given 8 | settings.USE_REDIS = False 9 | 10 | # when 11 | response = app_runner.get("/api/ready") 12 | 13 | # then 14 | assert response.status_code == 200 15 | assert response.json() == {"status": "ok"} 16 | 17 | def test_should_return_not_found_when_invalid_uri(self, app_runner): 18 | # given / when 19 | response = app_runner.get("/api/ready/123") 20 | 21 | # then 22 | assert response.status_code == 404 23 | 24 | def test_should_return_bad_gateway_when_redis_unavailable(self, app_runner): 25 | # given 26 | settings.USE_REDIS = True 27 | 28 | # when 29 | response = app_runner.get("/api/ready") 30 | 31 | # then 32 | assert response.status_code == 502 33 | assert response.json() == { 34 | "error": { 35 | "code": 502, 36 | "message": "Could not connect to Redis", 37 | "status": "BAD_GATEWAY", 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/unit/app/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/app/exceptions/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/exceptions/test_http.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from starlette.requests import Request 5 | from example.app.exceptions import ( 6 | HTTPException, 7 | http_exception_handler, 8 | ) 9 | 10 | 11 | class TestHttpException: 12 | 13 | @pytest.mark.parametrize( 14 | "status_code, content, headers", 15 | [ 16 | (400, "test msg", None), 17 | (403, "test msg", [{"key": 123, "key2": 123.123, "foo": "bar"}]), 18 | ( 19 | 404, 20 | {"key": 123, "key2": 123.123, "foo": "bar"}, 21 | {"key": {"foo": "bar"}, "key2": [1, 2, 3]}, 22 | ), 23 | ], 24 | ) 25 | def test_should_create_exception(self, status_code, content, headers): 26 | # given / when 27 | ex = HTTPException( 28 | status_code=status_code, 29 | content=content, 30 | headers=headers, 31 | ) 32 | 33 | # then 34 | assert issubclass(type(ex), Exception) 35 | assert ex.status_code == status_code 36 | assert ex.content == content 37 | assert ex.headers == headers 38 | 39 | def test_should_create_exception_from_repr_eval(self): 40 | # given 41 | ex = HTTPException( 42 | status_code=200, 43 | content="OK", 44 | headers=None, 45 | ) 46 | 47 | # when 48 | ex_eval = eval(repr(ex)) 49 | 50 | # then 51 | assert ex_eval.status_code == ex.status_code 52 | assert ex_eval.content == ex.content 53 | assert ex_eval.headers == ex.headers 54 | assert isinstance(ex_eval, HTTPException) 55 | 56 | 57 | class TestHttpExceptionHandler: 58 | 59 | @pytest.fixture 60 | def fastapi_request(self): 61 | yield mock.Mock(spec=Request) 62 | 63 | @pytest.mark.asyncio 64 | async def test_should_return_json_response(self, fastapi_request): 65 | # given 66 | ex = HTTPException( 67 | status_code=502, 68 | content=[{"key": 123, "key2": 123.123, "foo": "bar"}], 69 | headers={"foo": "bar"}, 70 | ) 71 | 72 | # when 73 | response = await http_exception_handler(fastapi_request, ex) 74 | 75 | # then 76 | assert response.status_code == 502 77 | assert response.body == b'[{"key":123,"key2":123.123,"foo":"bar"}]' 78 | assert response.headers["foo"] == "bar" 79 | -------------------------------------------------------------------------------- /tests/unit/app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/app/models/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/test_asgi.py: -------------------------------------------------------------------------------- 1 | from example.config import settings 2 | from example.app.router import root_api_router 3 | from example.app.asgi import ( 4 | get_application, 5 | on_startup, 6 | on_shutdown, 7 | ) 8 | from example.app.exceptions import ( 9 | HTTPException, 10 | http_exception_handler, 11 | ) 12 | 13 | 14 | class TestGetApplication: 15 | 16 | def test_should_create_app_and_populate_defaults(self): 17 | # given / when 18 | app = get_application() 19 | 20 | # then 21 | assert app.title == settings.PROJECT_NAME 22 | assert app.debug == settings.DEBUG 23 | assert app.version == settings.VERSION 24 | assert app.docs_url == settings.DOCS_URL 25 | assert app.router.on_startup == [on_startup] 26 | assert app.router.on_shutdown == [on_shutdown] 27 | assert all(r in app.routes for r in root_api_router.routes) 28 | assert app.exception_handlers[HTTPException] == http_exception_handler 29 | -------------------------------------------------------------------------------- /tests/unit/app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/app/utils/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/utils/test_aiohttp_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import aiohttp 3 | from aioresponses import aioresponses 4 | from example.app.utils import AiohttpClient 5 | 6 | 7 | class TestAiohttpClient: 8 | 9 | @pytest.fixture 10 | def fake_web(self): 11 | with aioresponses() as mock: 12 | yield mock 13 | 14 | def test_should_create_aiohttp_client(self): 15 | # given / when 16 | AiohttpClient.get_aiohttp_client() 17 | 18 | # then 19 | assert isinstance(AiohttpClient.aiohttp_client, aiohttp.ClientSession) 20 | 21 | @pytest.mark.asyncio 22 | async def test_should_close_aiohttp_client(self): 23 | # given 24 | AiohttpClient.get_aiohttp_client() 25 | 26 | # when 27 | await AiohttpClient.close_aiohttp_client() 28 | 29 | # then 30 | assert AiohttpClient.aiohttp_client is None 31 | 32 | @pytest.mark.asyncio 33 | @pytest.mark.parametrize( 34 | "status, headers, raise_for_status", 35 | [ 36 | (200, None, True), 37 | (201, {"foo": "bar"}, False), 38 | (404, None, False), 39 | (500, {"API": "KEY"}, False), 40 | ], 41 | ) 42 | async def test_should_execute_get_and_return_response(self, fake_web, status, headers, raise_for_status): 43 | # given 44 | fake_web.get( 45 | "http://example.com/api", 46 | status=status, 47 | payload={"fake": "response"}, 48 | ) 49 | AiohttpClient.get_aiohttp_client() 50 | 51 | # when 52 | response = await AiohttpClient.get( 53 | "http://example.com/api", 54 | headers=headers, 55 | raise_for_status=raise_for_status, 56 | ) 57 | 58 | # then 59 | assert response.status == status 60 | assert await response.json() == {"fake": "response"} 61 | if headers: 62 | assert response.request_info.headers == headers 63 | 64 | @pytest.mark.asyncio 65 | @pytest.mark.parametrize("status", [404, 500]) 66 | async def test_should_execute_get_and_raise(self, fake_web, status): 67 | # given 68 | fake_web.get( 69 | "http://example.com/api", 70 | status=status, 71 | payload={"fake": "response"}, 72 | ) 73 | AiohttpClient.get_aiohttp_client() 74 | 75 | # when / then 76 | with pytest.raises(aiohttp.ClientError): 77 | await AiohttpClient.get( 78 | "http://example.com/api", 79 | raise_for_status=True, 80 | ) 81 | 82 | @pytest.mark.asyncio 83 | @pytest.mark.parametrize( 84 | "status, data, headers, raise_for_status", 85 | [ 86 | (200, None, None, True), 87 | (201, {1: 2}, {"foo": "bar"}, True), 88 | (404, "payload", None, False), 89 | (500, None, {"API": "KEY"}, False), 90 | ], 91 | ) 92 | async def test_should_execute_post_and_return_response(self, fake_web, status, data, headers, raise_for_status): 93 | # given 94 | fake_web.post( 95 | "http://example.com/api", 96 | status=status, 97 | payload={"fake": "response"}, 98 | ) 99 | AiohttpClient.get_aiohttp_client() 100 | 101 | # when 102 | response = await AiohttpClient.post( 103 | "http://example.com/api", 104 | headers=headers, 105 | raise_for_status=raise_for_status, 106 | ) 107 | 108 | # then 109 | assert response.status == status 110 | assert await response.json() == {"fake": "response"} 111 | if headers: 112 | assert response.request_info.headers == headers 113 | 114 | @pytest.mark.asyncio 115 | @pytest.mark.parametrize("status", [404, 500]) 116 | async def test_should_execute_post_and_raise(self, fake_web, status): 117 | # given 118 | fake_web.post( 119 | "http://example.com/api", 120 | status=status, 121 | payload={"fake": "response"}, 122 | ) 123 | AiohttpClient.get_aiohttp_client() 124 | 125 | # when / then 126 | with pytest.raises(aiohttp.ClientError): 127 | await AiohttpClient.post( 128 | "http://example.com/api", 129 | raise_for_status=True, 130 | ) 131 | 132 | @pytest.mark.asyncio 133 | @pytest.mark.parametrize( 134 | "status, data, headers, raise_for_status", 135 | [ 136 | (200, None, None, True), 137 | (201, {1: 2}, {"foo": "bar"}, True), 138 | (404, "payload", None, False), 139 | (500, None, {"API": "KEY"}, False), 140 | ], 141 | ) 142 | async def test_should_execute_put_and_return_response(self, fake_web, status, data, headers, raise_for_status): 143 | # given 144 | fake_web.put( 145 | "http://example.com/api", 146 | status=status, 147 | payload={"fake": "response"}, 148 | ) 149 | AiohttpClient.get_aiohttp_client() 150 | 151 | # when 152 | response = await AiohttpClient.put( 153 | "http://example.com/api", 154 | headers=headers, 155 | raise_for_status=raise_for_status, 156 | ) 157 | 158 | # then 159 | assert response.status == status 160 | assert await response.json() == {"fake": "response"} 161 | if headers: 162 | assert response.request_info.headers == headers 163 | 164 | @pytest.mark.asyncio 165 | @pytest.mark.parametrize("status", [404, 500]) 166 | async def test_should_execute_put_and_raise(self, fake_web, status): 167 | # given 168 | fake_web.put( 169 | "http://example.com/api", 170 | status=status, 171 | payload={"fake": "response"}, 172 | ) 173 | AiohttpClient.get_aiohttp_client() 174 | 175 | # when / then 176 | with pytest.raises(aiohttp.ClientError): 177 | await AiohttpClient.put( 178 | "http://example.com/api", 179 | raise_for_status=True, 180 | ) 181 | 182 | @pytest.mark.asyncio 183 | @pytest.mark.parametrize( 184 | "status, data, headers, raise_for_status", 185 | [ 186 | (200, None, None, True), 187 | (201, {1: 2}, {"foo": "bar"}, True), 188 | (404, "payload", None, False), 189 | (500, None, {"API": "KEY"}, False), 190 | ], 191 | ) 192 | async def test_should_execute_patch_and_return_response(self, fake_web, status, data, headers, raise_for_status): 193 | # given 194 | fake_web.patch( 195 | "http://example.com/api", 196 | status=status, 197 | payload={"fake": "response"}, 198 | ) 199 | AiohttpClient.get_aiohttp_client() 200 | 201 | # when 202 | response = await AiohttpClient.patch( 203 | "http://example.com/api", 204 | headers=headers, 205 | raise_for_status=raise_for_status, 206 | ) 207 | 208 | # then 209 | assert response.status == status 210 | assert await response.json() == {"fake": "response"} 211 | if headers: 212 | assert response.request_info.headers == headers 213 | 214 | @pytest.mark.asyncio 215 | @pytest.mark.parametrize("status", [404, 500]) 216 | async def test_should_execute_patch_and_raise(self, fake_web, status): 217 | # given 218 | fake_web.patch( 219 | "http://example.com/api", 220 | status=status, 221 | payload={"fake": "response"}, 222 | ) 223 | AiohttpClient.get_aiohttp_client() 224 | 225 | # when / then 226 | with pytest.raises(aiohttp.ClientError): 227 | await AiohttpClient.patch( 228 | "http://example.com/api", 229 | raise_for_status=True, 230 | ) 231 | 232 | @pytest.mark.asyncio 233 | @pytest.mark.parametrize( 234 | "status, headers, raise_for_status", 235 | [ 236 | (200, None, True), 237 | (201, {"foo": "bar"}, False), 238 | (404, None, False), 239 | (500, {"API": "KEY"}, False), 240 | ], 241 | ) 242 | async def test_should_execute_delete_and_return_response(self, fake_web, status, headers, raise_for_status): 243 | # given 244 | fake_web.delete( 245 | "http://example.com/api", 246 | status=status, 247 | payload={"fake": "response"}, 248 | ) 249 | AiohttpClient.get_aiohttp_client() 250 | 251 | # when 252 | response = await AiohttpClient.delete( 253 | "http://example.com/api", 254 | headers=headers, 255 | raise_for_status=raise_for_status, 256 | ) 257 | 258 | # then 259 | assert response.status == status 260 | assert await response.json() == {"fake": "response"} 261 | if headers: 262 | assert response.request_info.headers == headers 263 | 264 | @pytest.mark.asyncio 265 | @pytest.mark.parametrize("status", [404, 500]) 266 | async def test_should_execute_delete_and_raise(self, fake_web, status): 267 | # given 268 | fake_web.delete( 269 | "http://example.com/api", 270 | status=status, 271 | payload={"fake": "response"}, 272 | ) 273 | AiohttpClient.get_aiohttp_client() 274 | 275 | # when / then 276 | with pytest.raises(aiohttp.ClientError): 277 | await AiohttpClient.delete( 278 | "http://example.com/api", 279 | raise_for_status=True, 280 | ) 281 | -------------------------------------------------------------------------------- /tests/unit/app/utils/test_redis.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from redis import asyncio as aioredis 5 | from example.app.utils import RedisClient 6 | from example.config import redis as redis_conf 7 | 8 | 9 | class TestRedisClient: 10 | 11 | @pytest.fixture 12 | def async_mock(self): 13 | yield mock.AsyncMock() 14 | 15 | def test_should_create_client_and_populate_defaults(self): 16 | # given / when 17 | RedisClient.open_redis_client() 18 | 19 | # then 20 | client = RedisClient.redis_client 21 | assert isinstance(client, aioredis.Redis) 22 | connection_kwargs = client.connection_pool.connection_kwargs 23 | assert connection_kwargs["port"] == redis_conf.REDIS_PORT 24 | assert connection_kwargs["host"] == redis_conf.REDIS_HOST 25 | 26 | def test_should_create_client_with_auth(self): 27 | # given 28 | redis_conf.REDIS_USERNAME = "John" 29 | redis_conf.REDIS_PASSWORD = "Secret" 30 | 31 | # when 32 | RedisClient.redis_client = None 33 | RedisClient.open_redis_client() 34 | 35 | # then 36 | client = RedisClient.redis_client 37 | assert isinstance(client, aioredis.Redis) 38 | connection_kwargs = client.connection_pool.connection_kwargs 39 | assert connection_kwargs["username"] == "John" 40 | assert connection_kwargs["password"] == "Secret" 41 | 42 | def test_should_create_sentinel_client(self): 43 | # given 44 | redis_conf.REDIS_USE_SENTINEL = True 45 | 46 | # when 47 | RedisClient.redis_client = None 48 | RedisClient.open_redis_client() 49 | 50 | # then 51 | client = RedisClient.redis_client 52 | assert isinstance(client, aioredis.Redis) 53 | assert client.connection_pool.service_name == "mymaster" 54 | 55 | @pytest.mark.asyncio 56 | async def test_should_execute_ping_and_return_true(self, async_mock): 57 | # given 58 | RedisClient.open_redis_client() 59 | RedisClient.redis_client.ping = async_mock 60 | async_mock.return_value = True 61 | 62 | # when 63 | result = await RedisClient.ping() 64 | 65 | # then 66 | assert result 67 | async_mock.assert_called_once() 68 | 69 | @pytest.mark.asyncio 70 | async def test_should_execute_ping_and_return_false(self, async_mock): 71 | # given 72 | RedisClient.open_redis_client() 73 | RedisClient.redis_client.ping = async_mock 74 | async_mock.side_effect = aioredis.RedisError("Fake error") 75 | 76 | result = await RedisClient.ping() 77 | 78 | # then 79 | assert not result 80 | async_mock.assert_called_once() 81 | 82 | @pytest.mark.asyncio 83 | async def test_should_execute_set_and_return_response(self, async_mock): 84 | # given 85 | RedisClient.open_redis_client() 86 | RedisClient.redis_client.set = async_mock 87 | async_mock.return_value = "OK" 88 | 89 | # when 90 | result = await RedisClient.set("key", "value") 91 | 92 | # then 93 | assert result == "OK" 94 | async_mock.assert_called_once_with("key", "value") 95 | 96 | @pytest.mark.asyncio 97 | async def test_should_execute_set_and_raise(self, async_mock): 98 | # given 99 | RedisClient.open_redis_client() 100 | RedisClient.redis_client.set = async_mock 101 | async_mock.side_effect = aioredis.RedisError("Fake error") 102 | 103 | # when / then 104 | with pytest.raises(aioredis.RedisError): 105 | await RedisClient.set("key", "value") 106 | 107 | @pytest.mark.asyncio 108 | async def test_should_execute_rpush_and_return_response(self, async_mock): 109 | # given 110 | RedisClient.open_redis_client() 111 | RedisClient.redis_client.rpush = async_mock 112 | async_mock.return_value = 10 113 | 114 | # when 115 | result = await RedisClient.rpush("key", "value") 116 | 117 | # then 118 | assert result == 10 119 | async_mock.assert_called_once_with("key", "value") 120 | 121 | @pytest.mark.asyncio 122 | async def test_should_execute_rpush_and_raise(self, async_mock): 123 | # given 124 | RedisClient.open_redis_client() 125 | RedisClient.redis_client.rpush = async_mock 126 | async_mock.side_effect = aioredis.RedisError("Fake error") 127 | 128 | # when / then 129 | with pytest.raises(aioredis.RedisError): 130 | await RedisClient.rpush("key", "value") 131 | 132 | @pytest.mark.asyncio 133 | async def test_should_execute_exists_and_return_response(self, async_mock): 134 | # given 135 | RedisClient.open_redis_client() 136 | RedisClient.redis_client.exists = async_mock 137 | async_mock.return_value = True 138 | 139 | # when 140 | result = await RedisClient.exists("key") 141 | 142 | # then 143 | assert result 144 | async_mock.assert_called_once_with("key") 145 | 146 | @pytest.mark.asyncio 147 | async def test_should_execute_exists_and_raise(self, async_mock): 148 | # given 149 | RedisClient.open_redis_client() 150 | RedisClient.redis_client.exists = async_mock 151 | async_mock.side_effect = aioredis.RedisError("Fake error") 152 | 153 | # when / then 154 | with pytest.raises(aioredis.RedisError): 155 | await RedisClient.exists("key") 156 | 157 | @pytest.mark.asyncio 158 | async def test_should_execute_get_and_return_response(self, async_mock): 159 | # given 160 | RedisClient.open_redis_client() 161 | RedisClient.redis_client.get = async_mock 162 | async_mock.return_value = "value" 163 | 164 | # when 165 | result = await RedisClient.get("key") 166 | 167 | # then 168 | assert result == "value" 169 | async_mock.assert_called_once_with("key") 170 | 171 | @pytest.mark.asyncio 172 | async def test_should_execute_get_and_raise(self, async_mock): 173 | # given 174 | RedisClient.open_redis_client() 175 | RedisClient.redis_client.get = async_mock 176 | async_mock.side_effect = aioredis.RedisError("Fake error") 177 | 178 | # when / then 179 | with pytest.raises(aioredis.RedisError): 180 | await RedisClient.get("key") 181 | 182 | @pytest.mark.asyncio 183 | async def test_should_execute_lrange_and_return_response(self, async_mock): 184 | # given 185 | RedisClient.open_redis_client() 186 | RedisClient.redis_client.lrange = async_mock 187 | async_mock.return_value = ["value", "value2"] 188 | 189 | # when 190 | result = await RedisClient.lrange("key", 1, -1) 191 | 192 | # then 193 | assert result == ["value", "value2"] 194 | async_mock.assert_called_once_with("key", 1, -1) 195 | 196 | @pytest.mark.asyncio 197 | async def test_should_execute_lrange_and_raise(self, async_mock): 198 | # given 199 | RedisClient.open_redis_client() 200 | RedisClient.redis_client.lrange = async_mock 201 | async_mock.side_effect = aioredis.RedisError("Fake error") 202 | 203 | # when / then 204 | with pytest.raises(aioredis.RedisError): 205 | await RedisClient.lrange("key", 1, -1) 206 | -------------------------------------------------------------------------------- /tests/unit/app/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/app/views/__init__.py -------------------------------------------------------------------------------- /tests/unit/app/views/test_error.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from pydantic.error_wrappers import ValidationError 5 | from example.app.views.error import ErrorModel, ErrorResponse 6 | 7 | 8 | class TestErrorModel: 9 | 10 | @pytest.mark.parametrize( 11 | "code, message, details", 12 | [ 13 | (400, "test msg", None), 14 | ("500", "test msg", [{}]), 15 | (403, "test msg", [{"key": 123, "key2": 123.123, "foo": "bar"}]), 16 | (404, "test msg", [{"key": {"foo": "bar"}, "key2": [1, 2, 3]}]), 17 | ("401", "test msg", None), 18 | ], 19 | ) 20 | def test_should_create_error_model(self, code, message, details): 21 | # given / when 22 | error = ErrorModel(code=code, message=message, details=details) 23 | 24 | # then 25 | assert error.code == int(code) 26 | assert error.message == message 27 | assert error.status == HTTPStatus(int(code)).name 28 | assert error.details == details 29 | schema = error.schema() 30 | assert schema["description"] == "Error model." 31 | assert schema["properties"]["status"] == { 32 | "title": "Status", 33 | "type": "string", 34 | } 35 | assert "status" in schema["required"] 36 | 37 | @pytest.mark.parametrize( 38 | "code, message, details", 39 | [ 40 | (500, {}, [{}]), 41 | (403, "test msg", "foobar"), 42 | (None, None, 123), 43 | ({}, [], None), 44 | (False, "test msg", None), 45 | ], 46 | ) 47 | def test_should_raise_when_invalid_arguments(self, code, message, details): 48 | # given / when / then 49 | with pytest.raises(ValidationError): 50 | ErrorModel(code=code, message=message, details=details) 51 | 52 | 53 | class TestErrorResponse: 54 | 55 | @pytest.mark.parametrize( 56 | "code, message, details", 57 | [ 58 | (400, "test msg", None), 59 | ("500", "test msg", [{}]), 60 | (403, "test msg", [{"key": 123, "key2": 123.123, "foo": "bar"}]), 61 | (404, "test msg", [{"key": {"foo": "bar"}, "key2": [1, 2, 3]}]), 62 | ("401", "test msg", None), 63 | ], 64 | ) 65 | def test_should_create_error_response(self, code, message, details): 66 | # given / when 67 | response = ErrorResponse(code=code, message=message, details=details) 68 | 69 | # then 70 | assert response.error.code == int(code) 71 | assert response.error.message == message 72 | assert response.error.status == HTTPStatus(int(code)).name 73 | assert response.error.details == details 74 | schema = response.schema() 75 | assert schema["description"] == "Error response model." 76 | 77 | @pytest.mark.parametrize( 78 | "code, message, details", 79 | [ 80 | (500, {}, [{}]), 81 | (403, "test msg", "foobar"), 82 | (None, None, 123), 83 | ({}, [], None), 84 | (False, "test msg", None), 85 | ], 86 | ) 87 | def test_should_raise_when_invalid_arguments(self, code, message, details): 88 | # given / when / then 89 | with pytest.raises(ValidationError): 90 | ErrorResponse(code=code, message=message, details=details) 91 | -------------------------------------------------------------------------------- /tests/unit/app/views/test_ready.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic.error_wrappers import ValidationError 3 | from example.app.views import ReadyResponse 4 | 5 | 6 | class TestReadyResponse: 7 | 8 | @pytest.mark.parametrize( 9 | "value", 10 | [ 11 | "ok", 12 | "Another string", 13 | "ąŻŹÐĄŁĘ®ŒĘŚÐ", 14 | 15, 15 | False, 16 | ], 17 | ) 18 | def test_should_create_ready_response(self, value): 19 | # given / when 20 | ready = ReadyResponse(status=value) 21 | 22 | # then 23 | assert ready.status == str(value) 24 | schema = ready.schema() 25 | assert schema["description"] == "Ready response model." 26 | 27 | @pytest.mark.parametrize( 28 | "value", 29 | [ 30 | ({"status": "ok"}), 31 | ([123, "ok"]), 32 | (["ok", "ready"]), 33 | ], 34 | ) 35 | def test_should_raise_when_invalid_argument(self, value): 36 | # given / when / then 37 | with pytest.raises(ValidationError): 38 | ReadyResponse(status=value) 39 | -------------------------------------------------------------------------------- /tests/unit/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastapi-mvc/example/6b29b3fe3dc9004aa7492c4d2ecd569fdf7e3ec3/tests/unit/cli/__init__.py -------------------------------------------------------------------------------- /tests/unit/cli/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from click.testing import CliRunner 3 | 4 | 5 | @pytest.fixture 6 | def cli_runner(): 7 | yield CliRunner() 8 | -------------------------------------------------------------------------------- /tests/unit/cli/test_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from example.cli import cli 3 | 4 | 5 | class TestCliRoot: 6 | 7 | def test_should_exit_zero_when_invoked_empty(self, cli_runner): 8 | # given / when 9 | result = cli_runner.invoke(cli) 10 | 11 | # then 12 | assert result.exit_code == 0 13 | 14 | def test_should_exit_zero_when_invoked_with_help(self, cli_runner): 15 | # given / when 16 | result = cli_runner.invoke(cli, ["--help"]) 17 | 18 | # then 19 | assert result.exit_code == 0 20 | 21 | def test_should_exit_error_when_invoked_with_invalid_option(self, cli_runner): 22 | # given / when 23 | result = cli_runner.invoke(cli, ["--not_exists"]) 24 | 25 | # then 26 | assert result.exit_code == 2 27 | 28 | @pytest.mark.parametrize("args", [ 29 | ["serve", "--help"], 30 | ["--verbose", "serve", "--help"] 31 | ]) 32 | def test_should_exit_zero_when_invoked_with_options(self, cli_runner, args): 33 | # given / when 34 | result = cli_runner.invoke(cli, args) 35 | 36 | # then 37 | assert result.exit_code == 0 38 | -------------------------------------------------------------------------------- /tests/unit/cli/test_serve.py: -------------------------------------------------------------------------------- 1 | import os 2 | import copy 3 | from unittest import mock 4 | 5 | import pytest 6 | from example import ApplicationLoader 7 | from example.cli.serve import serve 8 | 9 | fake_pid_file = os.path.join( 10 | os.path.dirname(__file__), "test.pid", 11 | ) 12 | 13 | 14 | class TestCliServeCommand: 15 | 16 | @pytest.fixture 17 | def patched_serve(self, asgi_app): 18 | cmd = copy.deepcopy(serve) 19 | wsgi_patch = mock.patch( 20 | "example.cli.serve.ApplicationLoader", spec=ApplicationLoader, 21 | ) 22 | get_app_patch = mock.patch( 23 | "example.cli.serve.get_application", return_value=asgi_app, 24 | ) 25 | cmd.wsgi_mock = wsgi_patch.start() 26 | cmd.get_app_mock = get_app_patch.start() 27 | yield cmd 28 | wsgi_patch.stop() 29 | get_app_patch.stop() 30 | del cmd 31 | 32 | def test_should_exit_zero_when_invoked_with_help(self, cli_runner): 33 | # given / when 34 | result = cli_runner.invoke(serve, ["--help"]) 35 | 36 | # then 37 | assert result.exit_code == 0 38 | 39 | def test_should_exit_error_when_invoked_with_invalid_option(self, cli_runner): 40 | # given / when 41 | result = cli_runner.invoke(serve, ["--not_exists"]) 42 | 43 | # then 44 | assert result.exit_code == 2 45 | 46 | @pytest.mark.parametrize( 47 | "args, expected", 48 | [ 49 | ( 50 | [], 51 | {}, 52 | ), 53 | ( 54 | ["--bind", "localhost:5000", "-w", 2], 55 | { 56 | "bind": "localhost:5000", 57 | "workers": 2, 58 | }, 59 | ), 60 | ( 61 | [ 62 | "--bind", 63 | "localhost:5000", 64 | "-w", 65 | 2, 66 | "--daemon", 67 | "--env", 68 | "FOO=BAR", 69 | "--env", 70 | "USE_FORCE=True", 71 | "--pid", 72 | fake_pid_file, 73 | ], 74 | { 75 | "bind": "localhost:5000", 76 | "workers": 2, 77 | "daemon": True, 78 | "raw_env": ("FOO=BAR", "USE_FORCE=True"), 79 | "pidfile": fake_pid_file, 80 | }, 81 | ), 82 | ( 83 | [ 84 | "--bind", 85 | "localhost:5000", 86 | "-w", 87 | 2, 88 | "-D", 89 | "-e", 90 | "FOO=BAR", 91 | "-e", 92 | "USE_FORCE=True", 93 | "--pid", 94 | fake_pid_file, 95 | ], 96 | { 97 | "bind": "localhost:5000", 98 | "workers": 2, 99 | "daemon": True, 100 | "raw_env": ("FOO=BAR", "USE_FORCE=True"), 101 | "pidfile": fake_pid_file, 102 | }, 103 | ), 104 | ], 105 | ) 106 | def test_should_create_wsgi_app_with_parsed_arguments(self, cli_runner, patched_serve, args, expected): 107 | # given / when 108 | result = cli_runner.invoke(patched_serve, args) 109 | 110 | # then 111 | assert result.exit_code == 0 112 | patched_serve.wsgi_mock.assert_called_once_with( 113 | application=patched_serve.get_app_mock.return_value, 114 | overrides=expected, 115 | ) 116 | patched_serve.wsgi_mock.return_value.run.assert_called_once() 117 | 118 | def test_should_exit_error_when_invalid_pid_file_given(self, cli_runner): 119 | # given / when 120 | result = cli_runner.invoke(serve, ["--pid", "/path/does/not/exist"]) 121 | 122 | # then 123 | assert result.exit_code == 2 124 | -------------------------------------------------------------------------------- /tests/unit/cli/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | import pytest 5 | from click import BadParameter 6 | from example.cli.utils import validate_directory 7 | 8 | 9 | class TestValidateDirectory: 10 | 11 | def test_should_return_original_value(self): 12 | # given / when 13 | path = os.path.dirname(__file__) 14 | result = validate_directory( 15 | mock.Mock(), 16 | mock.Mock(), 17 | path, 18 | ) 19 | 20 | # then 21 | assert result == path 22 | 23 | def test_should_raise_when_path_not_exists(self): 24 | # given 25 | path = "/path/does/not/exist" 26 | 27 | # when / then 28 | with pytest.raises(BadParameter): 29 | validate_directory( 30 | mock.Mock(), 31 | mock.Mock(), 32 | path, 33 | ) 34 | 35 | def test_should_raise_when_path_not_writable(self): 36 | # given 37 | path = "/etc" 38 | 39 | # when / then 40 | with pytest.raises(BadParameter): 41 | validate_directory( 42 | mock.Mock(), 43 | mock.Mock(), 44 | path, 45 | ) 46 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from example.app import get_application 5 | 6 | 7 | @pytest.fixture 8 | def asgi_app(): 9 | app = mock.Mock(spec=get_application()) 10 | yield app 11 | del app 12 | -------------------------------------------------------------------------------- /tests/unit/test_wsgi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from example import ApplicationLoader 3 | 4 | 5 | class TestApplicationLoader: 6 | 7 | def test_should_create_wsgi_and_populate_defaults(self, asgi_app): 8 | # given / when 9 | wsgi = ApplicationLoader(asgi_app) 10 | 11 | # then 12 | assert wsgi.load() == asgi_app 13 | assert wsgi.cfg.worker_class_str == "uvicorn.workers.UvicornWorker" 14 | assert wsgi.cfg.address == [("127.0.0.1", 8000)] 15 | assert wsgi.cfg.env == {} 16 | assert wsgi.cfg.settings["bind"].value == ["127.0.0.1:8000"] 17 | assert wsgi.cfg.settings["raw_env"].value == [] 18 | assert wsgi.cfg.settings["workers"].value == 2 19 | assert not wsgi.cfg.settings["daemon"].value 20 | assert not wsgi.cfg.settings["pidfile"].value 21 | 22 | def test_should_create_wsgi_and_override_config(self, asgi_app): 23 | # given / when 24 | wsgi = ApplicationLoader( 25 | application=asgi_app, 26 | overrides={ 27 | "raw_env": ("FOOBAR=123",), 28 | "bind": "0.0.0.0:3000", 29 | "workers": 3, 30 | "daemon": True, 31 | "pidfile": "/tmp/api.pid" 32 | } 33 | ) 34 | 35 | # then 36 | assert wsgi.cfg.address == [("0.0.0.0", 3000)] 37 | assert wsgi.cfg.env == {"FOOBAR": "123"} 38 | assert wsgi.cfg.settings["bind"].value == ["0.0.0.0:3000"] 39 | assert wsgi.cfg.settings["raw_env"].value == ["FOOBAR=123"] 40 | assert wsgi.cfg.settings["workers"].value == 3 41 | assert wsgi.cfg.settings["daemon"].value 42 | assert wsgi.cfg.settings["pidfile"].value == "/tmp/api.pid" 43 | 44 | def test_should_raise_when_invalid_override_given(self, asgi_app): 45 | # given 46 | overrides = { 47 | "unknown": True, 48 | "workers": None, 49 | } 50 | 51 | # when / then 52 | with pytest.raises(SystemExit): 53 | ApplicationLoader(application=asgi_app, overrides=overrides) 54 | --------------------------------------------------------------------------------