├── .env.example ├── .github ├── dependabot.yml └── workflows │ ├── dev_build.yml │ ├── tests.yml │ └── type_check.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ └── 20250602_2142_initial_migration_be24780c0da0.py ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── api_messages.py │ ├── api_router.py │ ├── deps.py │ └── endpoints │ │ ├── __init__.py │ │ ├── auth.py │ │ └── users.py ├── core │ ├── __init__.py │ ├── config.py │ ├── database_session.py │ └── security │ │ ├── __init__.py │ │ ├── jwt.py │ │ └── password.py ├── main.py ├── models.py ├── schemas │ ├── __init__.py │ ├── requests.py │ └── responses.py └── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_api_router_jwt_errors.py │ ├── test_auth │ ├── __init__.py │ ├── test_access_token.py │ ├── test_auth_refresh_token.py │ └── test_register_new_user.py │ ├── test_core │ ├── __init__.py │ ├── test_jwt.py │ └── test_password.py │ └── test_users │ ├── __init__.py │ ├── test_delete_current_user.py │ ├── test_read_current_user.py │ └── test_reset_password.py ├── docker-compose.yml ├── init.sh ├── poetry.lock └── pyproject.toml /.env.example: -------------------------------------------------------------------------------- 1 | SECURITY__JWT_SECRET_KEY=DVnFmhwvjEhJZpuhndxjhlezxQPJmBIIkMDEmFREWQADPcUnrG 2 | SECURITY__BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:8001"] 3 | SECURITY__ALLOWED_HOSTS=["localhost", "127.0.0.1"] 4 | 5 | DATABASE__HOSTNAME=localhost 6 | DATABASE__USERNAME=rDGJeEDqAz 7 | DATABASE__PASSWORD=XsPQhCoEfOQZueDjsILetLDUvbvSxAMnrVtgVZpmdcSssUgbvs 8 | DATABASE__PORT=5455 9 | DATABASE__DB=default_db -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 5 8 | allow: 9 | - dependency-type: "all" 10 | groups: 11 | all-dependencies: 12 | patterns: 13 | - "*" 14 | 15 | - package-ecosystem: github-actions 16 | directory: / 17 | schedule: 18 | interval: monthly 19 | 20 | - package-ecosystem: docker 21 | directory: / 22 | schedule: 23 | interval: monthly 24 | -------------------------------------------------------------------------------- /.github/workflows/dev_build.yml: -------------------------------------------------------------------------------- 1 | name: dev-build 2 | on: 3 | workflow_run: 4 | workflows: ["tests"] 5 | branches: [main] 6 | types: 7 | - completed 8 | 9 | workflow_dispatch: 10 | inputs: 11 | tag: 12 | description: "Docker image tag" 13 | required: true 14 | default: "latest" 15 | 16 | env: 17 | IMAGE_TAG: ${{ github.event.inputs.tag || 'latest' }} 18 | 19 | jobs: 20 | dev_build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Login to DockerHub 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKER_USER }} 29 | password: ${{ secrets.DOCKER_PASS }} 30 | 31 | - name: Build and push image 32 | uses: docker/build-push-action@v6 33 | with: 34 | file: Dockerfile 35 | push: true 36 | tags: rafsaf/minimal-fastapi-postgres-template:${{ env.IMAGE_TAG }} 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | tags-ignore: 7 | - "*.*" 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | services: 13 | postgres: 14 | image: postgres 15 | env: 16 | POSTGRES_PASSWORD: postgres 17 | options: >- 18 | --health-cmd pg_isready 19 | --health-interval 10s 20 | --health-timeout 5s 21 | --health-retries 5 22 | ports: 23 | - 5432:5432 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.13.1" 31 | 32 | - name: Install Poetry 33 | uses: snok/install-poetry@v1 34 | with: 35 | virtualenvs-create: true 36 | virtualenvs-in-project: false 37 | virtualenvs-path: /opt/venv 38 | 39 | - name: Load cached venv 40 | id: cached-poetry-dependencies 41 | uses: actions/cache@v4 42 | with: 43 | path: /opt/venv 44 | key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('poetry.lock') }} 45 | 46 | - name: Install dependencies and actiavte virtualenv 47 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 48 | run: | 49 | poetry install --no-interaction --no-root 50 | 51 | - name: Run tests 52 | env: 53 | SECURITY__JWT_SECRET_KEY: very-not-secret 54 | DATABASE__HOSTNAME: localhost 55 | DATABASE__PASSWORD: postgres 56 | run: | 57 | poetry run pytest 58 | -------------------------------------------------------------------------------- /.github/workflows/type_check.yml: -------------------------------------------------------------------------------- 1 | name: type-check 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | tags-ignore: 7 | - "*.*" 8 | 9 | jobs: 10 | type_check: 11 | strategy: 12 | matrix: 13 | check: ["ruff check", "mypy --check", "ruff format --check"] 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.13.1" 23 | 24 | - name: Install Poetry 25 | uses: snok/install-poetry@v1 26 | with: 27 | virtualenvs-create: true 28 | virtualenvs-in-project: true 29 | 30 | - name: Install Poetry 31 | uses: snok/install-poetry@v1 32 | with: 33 | virtualenvs-create: true 34 | virtualenvs-in-project: false 35 | virtualenvs-path: /opt/venv 36 | 37 | - name: Load cached venv 38 | id: cached-poetry-dependencies 39 | uses: actions/cache@v4 40 | with: 41 | path: /opt/venv 42 | key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('poetry.lock') }} 43 | 44 | - name: Install dependencies and actiavte virtualenv 45 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 46 | run: | 47 | poetry install --no-interaction --no-root 48 | 49 | - name: Run ${{ matrix.check }} 50 | run: | 51 | poetry run ${{ matrix.check }} . 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .env 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | log.txt 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # ruff 131 | .ruff_cache 132 | 133 | # Pyre type checker 134 | .pyre/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.11.12 9 | hooks: 10 | - id: ruff-format 11 | 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.11.12 14 | hooks: 15 | - id: ruff 16 | args: [--fix] 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.3-slim-bookworm AS base 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | WORKDIR /build 5 | 6 | # Create requirements.txt file 7 | FROM base AS poetry 8 | RUN pip install poetry==2.1.3 9 | RUN poetry self add poetry-plugin-export 10 | COPY poetry.lock pyproject.toml ./ 11 | RUN poetry export -o /requirements.txt --without-hashes 12 | 13 | FROM base AS final 14 | COPY --from=poetry /requirements.txt . 15 | 16 | # Create venv, add it to path and install requirements 17 | RUN python -m venv /venv 18 | ENV PATH="/venv/bin:$PATH" 19 | RUN pip install -r requirements.txt 20 | 21 | # Install uvicorn server 22 | RUN pip install uvicorn[standard] 23 | 24 | # Copy the rest of app 25 | COPY app app 26 | COPY alembic alembic 27 | COPY alembic.ini . 28 | COPY pyproject.toml . 29 | COPY init.sh . 30 | 31 | # Expose port 32 | EXPOSE 8000 33 | 34 | # Make the init script executable 35 | RUN chmod +x ./init.sh 36 | 37 | # Set ENTRYPOINT to always run init.sh 38 | ENTRYPOINT ["./init.sh"] 39 | 40 | # Set CMD to uvicorn 41 | # /venv/bin/uvicorn is used because from entrypoint script PATH is new 42 | CMD ["/venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2", "--loop", "uvloop"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 rafsaf 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Live example](https://img.shields.io/badge/live%20example-https%3A%2F%2Fminimal--fastapi--postgres--template.rafsaf.pl-blueviolet)](https://minimal-fastapi-postgres-template.rafsaf.pl/) 2 | [![License](https://img.shields.io/github/license/rafsaf/minimal-fastapi-postgres-template)](https://github.com/rafsaf/minimal-fastapi-postgres-template/blob/main/LICENSE) 3 | [![Python 3.13](https://img.shields.io/badge/python-3.13-blue)](https://docs.python.org/3/whatsnew/3.13.html) 4 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 5 | [![Tests](https://github.com/rafsaf/minimal-fastapi-postgres-template/actions/workflows/tests.yml/badge.svg)](https://github.com/rafsaf/minimal-fastapi-postgres-template/actions/workflows/tests.yml) 6 | 7 | _Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl, it's 100% code used in template (docker image) with added my domain and https only._ 8 | 9 | # Minimal async FastAPI + PostgreSQL template 10 | 11 | - [Minimal async FastAPI + PostgreSQL template](#minimal-async-fastapi--postgresql-template) 12 | - [Features](#features) 13 | - [Quickstart](#quickstart) 14 | - [1. Create repository from a template](#1-create-repository-from-a-template) 15 | - [2. Install dependecies with Poetry](#2-install-dependecies-with-poetry) 16 | - [3. Setup database and migrations](#3-setup-database-and-migrations) 17 | - [4. Now you can run app](#4-now-you-can-run-app) 18 | - [5. Activate pre-commit](#5-activate-pre-commit) 19 | - [6. Running tests](#6-running-tests) 20 | - [About](#about) 21 | - [Step by step example - POST and GET endpoints](#step-by-step-example---post-and-get-endpoints) 22 | - [1. Create SQLAlchemy model](#1-create-sqlalchemy-model) 23 | - [2. Create and apply alembic migration](#2-create-and-apply-alembic-migration) 24 | - [3. Create request and response schemas](#3-create-request-and-response-schemas) 25 | - [4. Create endpoints](#4-create-endpoints) 26 | - [5. Write tests](#5-write-tests) 27 | - [Design](#design) 28 | - [Deployment strategies - via Docker image](#deployment-strategies---via-docker-image) 29 | - [Docs URL, CORS and Allowed Hosts](#docs-url-cors-and-allowed-hosts) 30 | - [License](#license) 31 | 32 | 33 | ## Features 34 | 35 | - [x] Template repository 36 | - [x] SQLAlchemy 2.0, async queries, best possible autocompletion support 37 | - [x] PostgreSQL 16 database under `asyncpg`, docker-compose.yml 38 | - [x] Full [Alembic](https://alembic.sqlalchemy.org/en/latest/) migrations setup 39 | - [x] Refresh token endpoint (not only access like in official template) 40 | - [x] Ready to go Dockerfile with [uvicorn](https://www.uvicorn.org/) webserver as an example 41 | - [x] [Poetry](https://python-poetry.org/docs/), `mypy`, `pre-commit` hooks with [ruff](https://github.com/astral-sh/ruff) 42 | - [x] Perfect pytest asynchronous test setup with +40 tests and full coverage 43 | 44 |
45 | 46 | 47 | 48 | ![template-fastapi-minimal-openapi-example](https://drive.google.com/uc?export=view&id=1rIXFJK8VyVrV7v4qgtPFryDd5FQrb4gr) 49 | 50 | 51 | 52 | ## Quickstart 53 | 54 | ### 1. Create repository from a template 55 | 56 | See [docs](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template). 57 | 58 | ### 2. Install dependecies with [Poetry](https://python-poetry.org/docs/) 59 | 60 | ```bash 61 | cd your_project_name 62 | 63 | ### Poetry install (python3.13) 64 | poetry install 65 | ``` 66 | 67 | Note, be sure to use `python3.13` with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove new versions specific syntax for example `str | int` for python < 3.10) 68 | 69 | ### 3. Setup database and migrations 70 | 71 | ```bash 72 | ### Setup database 73 | docker-compose up -d 74 | 75 | ### Run Alembic migrations 76 | alembic upgrade head 77 | ``` 78 | 79 | ### 4. Now you can run app 80 | 81 | ```bash 82 | ### And this is it: 83 | uvicorn app.main:app --reload 84 | 85 | ``` 86 | 87 | You should then use `git init` (if needed) to initialize git repository and access OpenAPI spec at http://localhost:8000/ by default. To customize docs url, cors and allowed hosts settings, read [section about it](#docs-url-cors-and-allowed-hosts). 88 | 89 | ### 5. Activate pre-commit 90 | 91 | [pre-commit](https://pre-commit.com/) is de facto standard now for pre push activities like isort or black or its nowadays replacement ruff. 92 | 93 | Refer to `.pre-commit-config.yaml` file to see my current opinionated choices. 94 | 95 | ```bash 96 | # Install pre-commit 97 | pre-commit install --install-hooks 98 | 99 | # Run on all files 100 | pre-commit run --all-files 101 | ``` 102 | 103 | ### 6. Running tests 104 | 105 | Note, it will create databases for session and run tests in many processes by default (using pytest-xdist) to speed up execution, based on how many CPU are available in environment. 106 | 107 | For more details about initial database setup, see logic `app/tests/conftest.py` file, `fixture_setup_new_test_database` function. 108 | 109 | Moreover, there is coverage pytest plugin with required code coverage level 100%. 110 | 111 | ```bash 112 | # see all pytest configuration flags in pyproject.toml 113 | pytest 114 | ``` 115 | 116 |
117 | 118 | ## About 119 | 120 | This project is heavily based on the official template https://github.com/tiangolo/full-stack-fastapi-postgresql (and on my previous work: [link1](https://github.com/rafsaf/fastapi-plan), [link2](https://github.com/rafsaf/docker-fastapi-projects)), but as it now not too much up-to-date, it is much easier to create new one than change official. I didn't like some of conventions over there also (`crud` and `db` folders for example or `schemas` with bunch of files). This template aims to be as much up-to-date as possible, using only newest python versions and libraries versions. 121 | 122 | `2.0` style SQLAlchemy API is good enough so there is no need to write everything in `crud` and waste our time... The `core` folder was also rewritten. There is great base for writting tests in `tests`, but I didn't want to write hundreds of them, I noticed that usually after changes in the structure of the project, auto tests are useless and you have to write them from scratch anyway (delete old ones...), hence less than more. Similarly with the `User` model, it is very modest, with just `id` (uuid), `email` and `password_hash`, because it will be adapted to the project anyway. 123 | 124 | 2024 update: 125 | 126 | The template was adpoted to my current style and knowledge, the test based expanded to cover more, added mypy, ruff and test setup was completly rewritten to have three things: 127 | 128 | - run test in paraller in many processes for speed 129 | - transactions rollback after every test 130 | - create test databases instead of having another in docker-compose.yml 131 | 132 |
133 | 134 | ## Step by step example - POST and GET endpoints 135 | 136 | I always enjoy to have some kind of an example in templates (even if I don't like it much, _some_ parts may be useful and save my time...), so let's create two example endpoints: 137 | 138 | - `POST` endpoint `/pets/create` for creating `Pets` with relation to currently logged `User` 139 | - `GET` endpoint `/pets/me` for fetching all user's pets. 140 | 141 |
142 | 143 | ### 1. Create SQLAlchemy model 144 | 145 | We will add `Pet` model to `app/models.py`. 146 | 147 | ```python 148 | # app/models.py 149 | 150 | (...) 151 | 152 | class Pet(Base): 153 | __tablename__ = "pet" 154 | 155 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 156 | user_id: Mapped[str] = mapped_column( 157 | ForeignKey("user_account.user_id", ondelete="CASCADE"), 158 | ) 159 | pet_name: Mapped[str] = mapped_column(String(50), nullable=False) 160 | 161 | ``` 162 | 163 | Note, we are using super powerful SQLAlchemy feature here - Mapped and mapped_column were first introduced in SQLAlchemy 2.0, if this syntax is new for you, read carefully "what's new" part of documentation https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html. 164 | 165 |
166 | 167 | ### 2. Create and apply alembic migration 168 | 169 | ```bash 170 | ### Use below commands in root folder in virtualenv ### 171 | 172 | # if you see FAILED: Target database is not up to date. 173 | # first use alembic upgrade head 174 | 175 | # Create migration with alembic revision 176 | alembic revision --autogenerate -m "create_pet_model" 177 | 178 | 179 | # File similar to "2022050949_create_pet_model_44b7b689ea5f.py" should appear in `/alembic/versions` folder 180 | 181 | 182 | # Apply migration using alembic upgrade 183 | alembic upgrade head 184 | 185 | # (...) 186 | # INFO [alembic.runtime.migration] Running upgrade d1252175c146 -> 44b7b689ea5f, create_pet_model 187 | ``` 188 | 189 | PS. Note, alembic is configured in a way that it work with async setup and also detects specific column changes if using `--autogenerate` flag. 190 | 191 |
192 | 193 | ### 3. Create request and response schemas 194 | 195 | There are only 2 files: `requests.py` and `responses.py` in `schemas` folder and I would keep it that way even for few dozen of endpoints. Not to mention this is opinionated. 196 | 197 | ```python 198 | # app/schemas/requests.py 199 | 200 | (...) 201 | 202 | 203 | class PetCreateRequest(BaseRequest): 204 | pet_name: str 205 | 206 | ``` 207 | 208 | ```python 209 | # app/schemas/responses.py 210 | 211 | (...) 212 | 213 | 214 | class PetResponse(BaseResponse): 215 | id: int 216 | pet_name: str 217 | user_id: str 218 | 219 | ``` 220 | 221 |
222 | 223 | ### 4. Create endpoints 224 | 225 | ```python 226 | # app/api/endpoints/pets.py 227 | 228 | from fastapi import APIRouter, Depends, status 229 | from sqlalchemy import select 230 | from sqlalchemy.ext.asyncio import AsyncSession 231 | 232 | from app.api import deps 233 | from app.models import Pet, User 234 | from app.schemas.requests import PetCreateRequest 235 | from app.schemas.responses import PetResponse 236 | 237 | router = APIRouter() 238 | 239 | 240 | @router.post( 241 | "/create", 242 | response_model=PetResponse, 243 | status_code=status.HTTP_201_CREATED, 244 | description="Creates new pet. Only for logged users.", 245 | ) 246 | async def create_new_pet( 247 | data: PetCreateRequest, 248 | session: AsyncSession = Depends(deps.get_session), 249 | current_user: User = Depends(deps.get_current_user), 250 | ) -> Pet: 251 | new_pet = Pet(user_id=current_user.user_id, pet_name=data.pet_name) 252 | 253 | session.add(new_pet) 254 | await session.commit() 255 | 256 | return new_pet 257 | 258 | 259 | @router.get( 260 | "/me", 261 | response_model=list[PetResponse], 262 | status_code=status.HTTP_200_OK, 263 | description="Get list of pets for currently logged user.", 264 | ) 265 | async def get_all_my_pets( 266 | session: AsyncSession = Depends(deps.get_session), 267 | current_user: User = Depends(deps.get_current_user), 268 | ) -> list[Pet]: 269 | pets = await session.scalars( 270 | select(Pet).where(Pet.user_id == current_user.user_id).order_by(Pet.pet_name) 271 | ) 272 | 273 | return list(pets.all()) 274 | 275 | ``` 276 | 277 | Also, we need to add newly created endpoints to router. 278 | 279 | ```python 280 | # app/api/api.py 281 | 282 | (...) 283 | 284 | from app.api.endpoints import auth, pets, users 285 | 286 | (...) 287 | 288 | api_router.include_router(pets.router, prefix="/pets", tags=["pets"]) 289 | 290 | ``` 291 | 292 |
293 | 294 | ### 5. Write tests 295 | 296 | We will write two really simple tests in combined file inside newly created `app/tests/test_pets` folder. 297 | 298 | ```python 299 | # app/tests/test_pets/test_pets.py 300 | 301 | from fastapi import status 302 | from httpx import AsyncClient 303 | from sqlalchemy.ext.asyncio import AsyncSession 304 | 305 | from app.main import app 306 | from app.models import Pet, User 307 | 308 | 309 | async def test_create_new_pet( 310 | client: AsyncClient, default_user_headers: dict[str, str], default_user: User 311 | ) -> None: 312 | response = await client.post( 313 | app.url_path_for("create_new_pet"), 314 | headers=default_user_headers, 315 | json={"pet_name": "Tadeusz"}, 316 | ) 317 | assert response.status_code == status.HTTP_201_CREATED 318 | 319 | result = response.json() 320 | assert result["user_id"] == default_user.user_id 321 | assert result["pet_name"] == "Tadeusz" 322 | 323 | 324 | async def test_get_all_my_pets( 325 | client: AsyncClient, 326 | default_user_headers: dict[str, str], 327 | default_user: User, 328 | session: AsyncSession, 329 | ) -> None: 330 | pet1 = Pet(user_id=default_user.user_id, pet_name="Pet_1") 331 | pet2 = Pet(user_id=default_user.user_id, pet_name="Pet_2") 332 | 333 | session.add(pet1) 334 | session.add(pet2) 335 | await session.commit() 336 | 337 | response = await client.get( 338 | app.url_path_for("get_all_my_pets"), 339 | headers=default_user_headers, 340 | ) 341 | assert response.status_code == status.HTTP_200_OK 342 | 343 | assert response.json() == [ 344 | { 345 | "user_id": pet1.user_id, 346 | "pet_name": pet1.pet_name, 347 | "id": pet1.id, 348 | }, 349 | { 350 | "user_id": pet2.user_id, 351 | "pet_name": pet2.pet_name, 352 | "id": pet2.id, 353 | }, 354 | ] 355 | 356 | 357 | ``` 358 | 359 | ## Design 360 | 361 | ### Deployment strategies - via Docker image 362 | 363 | This template has by default included `Dockerfile` with [Uvicorn](https://www.uvicorn.org/) webserver, because it's simple and just for showcase purposes, with direct relation to FastAPI and great ease of configuration. You should be able to run container(s) (over :8000 port) and then need to setup the proxy, loadbalancer, with https enbaled, so the app stays behind it. 364 | 365 | If you prefer other webservers for FastAPI, check out [Nginx Unit](https://unit.nginx.org/), [Daphne](https://github.com/django/daphne), [Hypercorn](https://pgjones.gitlab.io/hypercorn/index.html). 366 | 367 | ### Docs URL, CORS and Allowed Hosts 368 | 369 | There are some **opinionated** default settings in `/app/main.py` for documentation, CORS and allowed hosts. 370 | 371 | 1. Docs 372 | 373 | ```python 374 | app = FastAPI( 375 | title="minimal fastapi postgres template", 376 | version="6.1.0", 377 | description="https://github.com/rafsaf/minimal-fastapi-postgres-template", 378 | openapi_url="/openapi.json", 379 | docs_url="/", 380 | ) 381 | ``` 382 | 383 | Docs page is simpy `/` (by default in FastAPI it is `/docs`). You can change it completely for the project, just as title, version, etc. 384 | 385 | 2. CORS 386 | 387 | ```python 388 | app.add_middleware( 389 | CORSMiddleware, 390 | allow_origins=[str(origin) for origin in config.settings.BACKEND_CORS_ORIGINS], 391 | allow_credentials=True, 392 | allow_methods=["*"], 393 | allow_headers=["*"], 394 | ) 395 | ``` 396 | 397 | If you are not sure what are CORS for, follow https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS. React and most frontend frameworks nowadays operate on `http://localhost:3000` thats why it's included in `BACKEND_CORS_ORIGINS` in .env file, before going production be sure to include your frontend domain here, like `https://my-fontend-app.example.com`. 398 | 399 | 3. Allowed Hosts 400 | 401 | ```python 402 | app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS) 403 | ``` 404 | 405 | Prevents HTTP Host Headers attack, you shoud put here you server IP or (preferably) full domain under it's accessible like `example.com`. By default in .env there are two most popular records: `ALLOWED_HOSTS=["localhost", "127.0.0.1"]` 406 | 407 | 408 | ## License 409 | 410 | The code is under MIT License. It's here for educational purposes, created mainly to have a place where up-to-date Python and FastAPI software lives. Do whatever you want with this code. -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s_%%(rev)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 18 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to ZoneInfo() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "slug" field 25 | truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to ${script_location}/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | # output_encoding = utf-8 60 | 61 | sqlalchemy.url = driver://user:pass@localhost/dbname 62 | 63 | [post_write_hooks] 64 | hooks = pre_commit 65 | pre_commit.type = console_scripts 66 | pre_commit.entrypoint = pre-commit 67 | pre_commit.options = run --files REVISION_SCRIPT_FILENAME 68 | # This section defines scripts or Python functions that are run 69 | # on newly generated revision scripts. See the documentation for further 70 | # detail and examples 71 | 72 | # format using "black" - use the console_scripts runner, 73 | # against the "black" entrypoint 74 | 75 | 76 | # Logging configuration 77 | [loggers] 78 | keys = root,sqlalchemy,alembic 79 | 80 | [handlers] 81 | keys = console 82 | 83 | [formatters] 84 | keys = generic 85 | 86 | [logger_root] 87 | level = WARN 88 | handlers = console 89 | qualname = 90 | 91 | [logger_sqlalchemy] 92 | level = WARN 93 | handlers = 94 | qualname = sqlalchemy.engine 95 | 96 | [logger_alembic] 97 | level = INFO 98 | handlers = 99 | qualname = alembic 100 | 101 | [handler_console] 102 | class = StreamHandler 103 | args = (sys.stderr,) 104 | level = NOTSET 105 | formatter = generic 106 | 107 | [formatter_generic] 108 | format = %(levelname)-5.5s [%(name)s] %(message)s 109 | datefmt = %H:%M:%S -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import Connection, engine_from_config, pool 5 | from sqlalchemy.ext.asyncio import AsyncEngine 6 | 7 | from alembic import context 8 | from app.core.config import get_settings 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) # type: ignore 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | from app.models import Base # noqa 23 | 24 | target_metadata = Base.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | 32 | def get_database_uri() -> str: 33 | return get_settings().sqlalchemy_database_uri.render_as_string(hide_password=False) 34 | 35 | 36 | def run_migrations_offline() -> None: 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = get_database_uri() 49 | context.configure( 50 | url=url, 51 | target_metadata=target_metadata, 52 | literal_binds=True, 53 | dialect_opts={"paramstyle": "named"}, 54 | compare_type=True, 55 | compare_server_default=True, 56 | ) 57 | 58 | with context.begin_transaction(): 59 | context.run_migrations() 60 | 61 | 62 | def do_run_migrations(connection: Connection | None) -> None: 63 | context.configure( 64 | connection=connection, target_metadata=target_metadata, compare_type=True 65 | ) 66 | 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | 70 | 71 | async def run_migrations_online() -> None: 72 | """Run migrations in 'online' mode. 73 | 74 | In this scenario we need to create an Engine 75 | and associate a connection with the context. 76 | 77 | """ 78 | configuration = config.get_section(config.config_ini_section) 79 | assert configuration 80 | configuration["sqlalchemy.url"] = get_database_uri() 81 | connectable = AsyncEngine( 82 | engine_from_config( 83 | configuration, 84 | prefix="sqlalchemy.", 85 | poolclass=pool.NullPool, 86 | future=True, 87 | ) 88 | ) 89 | async with connectable.connect() as connection: 90 | await connection.run_sync(do_run_migrations) 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | asyncio.run(run_migrations_online()) 97 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /alembic/versions/20250602_2142_initial_migration_be24780c0da0.py: -------------------------------------------------------------------------------- 1 | """initial_migration 2 | 3 | Revision ID: be24780c0da0 4 | Revises: 5 | Create Date: 2025-06-02 21:42:16.031375 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "be24780c0da0" 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "user_account", 24 | sa.Column("user_id", sa.Uuid(as_uuid=False), nullable=False), 25 | sa.Column("email", sa.String(length=256), nullable=False), 26 | sa.Column("hashed_password", sa.String(length=128), nullable=False), 27 | sa.Column( 28 | "create_time", 29 | sa.DateTime(timezone=True), 30 | server_default=sa.text("now()"), 31 | nullable=False, 32 | ), 33 | sa.Column( 34 | "update_time", 35 | sa.DateTime(timezone=True), 36 | server_default=sa.text("now()"), 37 | nullable=False, 38 | ), 39 | sa.PrimaryKeyConstraint("user_id"), 40 | ) 41 | op.create_index( 42 | op.f("ix_user_account_email"), "user_account", ["email"], unique=True 43 | ) 44 | op.create_table( 45 | "refresh_token", 46 | sa.Column("id", sa.BigInteger(), nullable=False), 47 | sa.Column("refresh_token", sa.String(length=512), nullable=False), 48 | sa.Column("used", sa.Boolean(), nullable=False), 49 | sa.Column("exp", sa.BigInteger(), nullable=False), 50 | sa.Column("user_id", sa.Uuid(as_uuid=False), nullable=False), 51 | sa.Column( 52 | "create_time", 53 | sa.DateTime(timezone=True), 54 | server_default=sa.text("now()"), 55 | nullable=False, 56 | ), 57 | sa.Column( 58 | "update_time", 59 | sa.DateTime(timezone=True), 60 | server_default=sa.text("now()"), 61 | nullable=False, 62 | ), 63 | sa.ForeignKeyConstraint( 64 | ["user_id"], ["user_account.user_id"], ondelete="CASCADE" 65 | ), 66 | sa.PrimaryKeyConstraint("id"), 67 | ) 68 | op.create_index( 69 | op.f("ix_refresh_token_refresh_token"), 70 | "refresh_token", 71 | ["refresh_token"], 72 | unique=True, 73 | ) 74 | # ### end Alembic commands ### 75 | 76 | 77 | def downgrade() -> None: 78 | # ### commands auto generated by Alembic - please adjust! ### 79 | op.drop_index(op.f("ix_refresh_token_refresh_token"), table_name="refresh_token") 80 | op.drop_table("refresh_token") 81 | op.drop_index(op.f("ix_user_account_email"), table_name="user_account") 82 | op.drop_table("user_account") 83 | # ### end Alembic commands ### 84 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/__init__.py -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/api_messages.py: -------------------------------------------------------------------------------- 1 | JWT_ERROR_USER_REMOVED = "User removed" 2 | PASSWORD_INVALID = "Incorrect email or password" 3 | REFRESH_TOKEN_NOT_FOUND = "Refresh token not found" 4 | REFRESH_TOKEN_EXPIRED = "Refresh token expired" 5 | REFRESH_TOKEN_ALREADY_USED = "Refresh token already used" 6 | EMAIL_ADDRESS_ALREADY_USED = "Cannot use this email address" 7 | -------------------------------------------------------------------------------- /app/api/api_router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api import api_messages 4 | from app.api.endpoints import auth, users 5 | 6 | auth_router = APIRouter() 7 | auth_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 8 | 9 | api_router = APIRouter( 10 | responses={ 11 | 401: { 12 | "description": "No `Authorization` access token header, token is invalid or user removed", 13 | "content": { 14 | "application/json": { 15 | "examples": { 16 | "not authenticated": { 17 | "summary": "No authorization token header", 18 | "value": {"detail": "Not authenticated"}, 19 | }, 20 | "invalid token": { 21 | "summary": "Token validation failed, decode failed, it may be expired or malformed", 22 | "value": {"detail": "Token invalid: {detailed error msg}"}, 23 | }, 24 | "removed user": { 25 | "summary": api_messages.JWT_ERROR_USER_REMOVED, 26 | "value": {"detail": api_messages.JWT_ERROR_USER_REMOVED}, 27 | }, 28 | } 29 | } 30 | }, 31 | }, 32 | } 33 | ) 34 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 35 | -------------------------------------------------------------------------------- /app/api/deps.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator 2 | from typing import Annotated 3 | 4 | from fastapi import Depends, HTTPException, status 5 | from fastapi.security import OAuth2PasswordBearer 6 | from sqlalchemy import select 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | 9 | from app.api import api_messages 10 | from app.core import database_session 11 | from app.core.security.jwt import verify_jwt_token 12 | from app.models import User 13 | 14 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/access-token") 15 | 16 | 17 | async def get_session() -> AsyncGenerator[AsyncSession]: 18 | async with database_session.get_async_session() as session: 19 | yield session 20 | 21 | 22 | async def get_current_user( 23 | token: Annotated[str, Depends(oauth2_scheme)], 24 | session: AsyncSession = Depends(get_session), 25 | ) -> User: 26 | token_payload = verify_jwt_token(token) 27 | 28 | user = await session.scalar(select(User).where(User.user_id == token_payload.sub)) 29 | 30 | if user is None: 31 | raise HTTPException( 32 | status_code=status.HTTP_401_UNAUTHORIZED, 33 | detail=api_messages.JWT_ERROR_USER_REMOVED, 34 | ) 35 | return user 36 | -------------------------------------------------------------------------------- /app/api/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/api/endpoints/__init__.py -------------------------------------------------------------------------------- /app/api/endpoints/auth.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import time 3 | from typing import Any 4 | 5 | from fastapi import APIRouter, Depends, HTTPException, status 6 | from fastapi.security import OAuth2PasswordRequestForm 7 | from sqlalchemy import select 8 | from sqlalchemy.exc import IntegrityError 9 | from sqlalchemy.ext.asyncio import AsyncSession 10 | 11 | from app.api import api_messages, deps 12 | from app.core.config import get_settings 13 | from app.core.security.jwt import create_jwt_token 14 | from app.core.security.password import ( 15 | DUMMY_PASSWORD, 16 | get_password_hash, 17 | verify_password, 18 | ) 19 | from app.models import RefreshToken, User 20 | from app.schemas.requests import RefreshTokenRequest, UserCreateRequest 21 | from app.schemas.responses import AccessTokenResponse, UserResponse 22 | 23 | router = APIRouter() 24 | 25 | ACCESS_TOKEN_RESPONSES: dict[int | str, dict[str, Any]] = { 26 | 400: { 27 | "description": "Invalid email or password", 28 | "content": { 29 | "application/json": {"example": {"detail": api_messages.PASSWORD_INVALID}} 30 | }, 31 | }, 32 | } 33 | 34 | REFRESH_TOKEN_RESPONSES: dict[int | str, dict[str, Any]] = { 35 | 400: { 36 | "description": "Refresh token expired or is already used", 37 | "content": { 38 | "application/json": { 39 | "examples": { 40 | "refresh token expired": { 41 | "summary": api_messages.REFRESH_TOKEN_EXPIRED, 42 | "value": {"detail": api_messages.REFRESH_TOKEN_EXPIRED}, 43 | }, 44 | "refresh token already used": { 45 | "summary": api_messages.REFRESH_TOKEN_ALREADY_USED, 46 | "value": {"detail": api_messages.REFRESH_TOKEN_ALREADY_USED}, 47 | }, 48 | } 49 | } 50 | }, 51 | }, 52 | 404: { 53 | "description": "Refresh token does not exist", 54 | "content": { 55 | "application/json": { 56 | "example": {"detail": api_messages.REFRESH_TOKEN_NOT_FOUND} 57 | } 58 | }, 59 | }, 60 | } 61 | 62 | 63 | @router.post( 64 | "/access-token", 65 | response_model=AccessTokenResponse, 66 | responses=ACCESS_TOKEN_RESPONSES, 67 | description="OAuth2 compatible token, get an access token for future requests using username and password", 68 | ) 69 | async def login_access_token( 70 | session: AsyncSession = Depends(deps.get_session), 71 | form_data: OAuth2PasswordRequestForm = Depends(), 72 | ) -> AccessTokenResponse: 73 | user = await session.scalar(select(User).where(User.email == form_data.username)) 74 | 75 | if user is None: 76 | # this is naive method to not return early 77 | verify_password(form_data.password, DUMMY_PASSWORD) 78 | 79 | raise HTTPException( 80 | status_code=status.HTTP_400_BAD_REQUEST, 81 | detail=api_messages.PASSWORD_INVALID, 82 | ) 83 | 84 | if not verify_password(form_data.password, user.hashed_password): 85 | raise HTTPException( 86 | status_code=status.HTTP_400_BAD_REQUEST, 87 | detail=api_messages.PASSWORD_INVALID, 88 | ) 89 | 90 | jwt_token = create_jwt_token(user_id=user.user_id) 91 | 92 | refresh_token = RefreshToken( 93 | user_id=user.user_id, 94 | refresh_token=secrets.token_urlsafe(32), 95 | exp=int(time.time() + get_settings().security.refresh_token_expire_secs), 96 | ) 97 | session.add(refresh_token) 98 | await session.commit() 99 | 100 | return AccessTokenResponse( 101 | access_token=jwt_token.access_token, 102 | expires_at=jwt_token.payload.exp, 103 | refresh_token=refresh_token.refresh_token, 104 | refresh_token_expires_at=refresh_token.exp, 105 | ) 106 | 107 | 108 | @router.post( 109 | "/refresh-token", 110 | response_model=AccessTokenResponse, 111 | responses=REFRESH_TOKEN_RESPONSES, 112 | description="OAuth2 compatible token, get an access token for future requests using refresh token", 113 | ) 114 | async def refresh_token( 115 | data: RefreshTokenRequest, 116 | session: AsyncSession = Depends(deps.get_session), 117 | ) -> AccessTokenResponse: 118 | token = await session.scalar( 119 | select(RefreshToken) 120 | .where(RefreshToken.refresh_token == data.refresh_token) 121 | .with_for_update(skip_locked=True) 122 | ) 123 | 124 | if token is None: 125 | raise HTTPException( 126 | status_code=status.HTTP_404_NOT_FOUND, 127 | detail=api_messages.REFRESH_TOKEN_NOT_FOUND, 128 | ) 129 | elif time.time() > token.exp: 130 | raise HTTPException( 131 | status_code=status.HTTP_400_BAD_REQUEST, 132 | detail=api_messages.REFRESH_TOKEN_EXPIRED, 133 | ) 134 | elif token.used: 135 | raise HTTPException( 136 | status_code=status.HTTP_400_BAD_REQUEST, 137 | detail=api_messages.REFRESH_TOKEN_ALREADY_USED, 138 | ) 139 | 140 | token.used = True 141 | session.add(token) 142 | 143 | jwt_token = create_jwt_token(user_id=token.user_id) 144 | 145 | refresh_token = RefreshToken( 146 | user_id=token.user_id, 147 | refresh_token=secrets.token_urlsafe(32), 148 | exp=int(time.time() + get_settings().security.refresh_token_expire_secs), 149 | ) 150 | session.add(refresh_token) 151 | await session.commit() 152 | 153 | return AccessTokenResponse( 154 | access_token=jwt_token.access_token, 155 | expires_at=jwt_token.payload.exp, 156 | refresh_token=refresh_token.refresh_token, 157 | refresh_token_expires_at=refresh_token.exp, 158 | ) 159 | 160 | 161 | @router.post( 162 | "/register", 163 | response_model=UserResponse, 164 | description="Create new user", 165 | status_code=status.HTTP_201_CREATED, 166 | ) 167 | async def register_new_user( 168 | new_user: UserCreateRequest, 169 | session: AsyncSession = Depends(deps.get_session), 170 | ) -> User: 171 | user = await session.scalar(select(User).where(User.email == new_user.email)) 172 | if user is not None: 173 | raise HTTPException( 174 | status_code=status.HTTP_400_BAD_REQUEST, 175 | detail=api_messages.EMAIL_ADDRESS_ALREADY_USED, 176 | ) 177 | 178 | user = User( 179 | email=new_user.email, 180 | hashed_password=get_password_hash(new_user.password), 181 | ) 182 | session.add(user) 183 | 184 | try: 185 | await session.commit() 186 | except IntegrityError: # pragma: no cover 187 | await session.rollback() 188 | 189 | raise HTTPException( 190 | status_code=status.HTTP_400_BAD_REQUEST, 191 | detail=api_messages.EMAIL_ADDRESS_ALREADY_USED, 192 | ) 193 | 194 | return user 195 | -------------------------------------------------------------------------------- /app/api/endpoints/users.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, status 2 | from sqlalchemy import delete 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from app.api import deps 6 | from app.core.security.password import get_password_hash 7 | from app.models import User 8 | from app.schemas.requests import UserUpdatePasswordRequest 9 | from app.schemas.responses import UserResponse 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/me", response_model=UserResponse, description="Get current user") 15 | async def read_current_user( 16 | current_user: User = Depends(deps.get_current_user), 17 | ) -> User: 18 | return current_user 19 | 20 | 21 | @router.delete( 22 | "/me", 23 | status_code=status.HTTP_204_NO_CONTENT, 24 | description="Delete current user", 25 | ) 26 | async def delete_current_user( 27 | current_user: User = Depends(deps.get_current_user), 28 | session: AsyncSession = Depends(deps.get_session), 29 | ) -> None: 30 | await session.execute(delete(User).where(User.user_id == current_user.user_id)) 31 | await session.commit() 32 | 33 | 34 | @router.post( 35 | "/reset-password", 36 | status_code=status.HTTP_204_NO_CONTENT, 37 | description="Update current user password", 38 | ) 39 | async def reset_current_user_password( 40 | user_update_password: UserUpdatePasswordRequest, 41 | session: AsyncSession = Depends(deps.get_session), 42 | current_user: User = Depends(deps.get_current_user), 43 | ) -> None: 44 | current_user.hashed_password = get_password_hash(user_update_password.password) 45 | session.add(current_user) 46 | await session.commit() 47 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/core/__init__.py -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | # File with environment variables and general configuration logic. 2 | # Env variables are combined in nested groups like "Security", "Database" etc. 3 | # So environment variable (case-insensitive) for jwt_secret_key will be "security__jwt_secret_key" 4 | # 5 | # Pydantic priority ordering: 6 | # 7 | # 1. (Most important, will overwrite everything) - environment variables 8 | # 2. `.env` file in root folder of project 9 | # 3. Default values 10 | # 11 | # "sqlalchemy_database_uri" is computed field that will create valid database URL 12 | # 13 | # See https://pydantic-docs.helpmanual.io/usage/settings/ 14 | # Note, complex types like lists are read as json-encoded strings. 15 | 16 | 17 | import logging.config 18 | from functools import lru_cache 19 | from pathlib import Path 20 | 21 | from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, computed_field 22 | from pydantic_settings import BaseSettings, SettingsConfigDict 23 | from sqlalchemy.engine.url import URL 24 | 25 | PROJECT_DIR = Path(__file__).parent.parent.parent 26 | 27 | 28 | class Security(BaseModel): 29 | jwt_issuer: str = "my-app" 30 | jwt_secret_key: SecretStr = SecretStr("sk-change-me") 31 | jwt_access_token_expire_secs: int = 24 * 3600 # 1d 32 | refresh_token_expire_secs: int = 28 * 24 * 3600 # 28d 33 | password_bcrypt_rounds: int = 12 34 | allowed_hosts: list[str] = ["localhost", "127.0.0.1"] 35 | backend_cors_origins: list[AnyHttpUrl] = [] 36 | 37 | 38 | class Database(BaseModel): 39 | hostname: str = "postgres" 40 | username: str = "postgres" 41 | password: SecretStr = SecretStr("passwd-change-me") 42 | port: int = 5432 43 | db: str = "postgres" 44 | 45 | 46 | class Settings(BaseSettings): 47 | security: Security = Field(default_factory=Security) 48 | database: Database = Field(default_factory=Database) 49 | log_level: str = "INFO" 50 | 51 | @computed_field # type: ignore[prop-decorator] 52 | @property 53 | def sqlalchemy_database_uri(self) -> URL: 54 | return URL.create( 55 | drivername="postgresql+asyncpg", 56 | username=self.database.username, 57 | password=self.database.password.get_secret_value(), 58 | host=self.database.hostname, 59 | port=self.database.port, 60 | database=self.database.db, 61 | ) 62 | 63 | model_config = SettingsConfigDict( 64 | env_file=f"{PROJECT_DIR}/.env", 65 | case_sensitive=False, 66 | env_nested_delimiter="__", 67 | ) 68 | 69 | 70 | @lru_cache(maxsize=1) 71 | def get_settings() -> Settings: 72 | return Settings() 73 | 74 | 75 | def logging_config(log_level: str) -> None: 76 | conf = { 77 | "version": 1, 78 | "disable_existing_loggers": False, 79 | "formatters": { 80 | "verbose": { 81 | "format": "{asctime} [{levelname}] {name}: {message}", 82 | "style": "{", 83 | }, 84 | }, 85 | "handlers": { 86 | "stream": { 87 | "class": "logging.StreamHandler", 88 | "formatter": "verbose", 89 | "level": "DEBUG", 90 | }, 91 | }, 92 | "loggers": { 93 | "": { 94 | "level": log_level, 95 | "handlers": ["stream"], 96 | "propagate": True, 97 | }, 98 | }, 99 | } 100 | logging.config.dictConfig(conf) 101 | 102 | 103 | logging_config(log_level=get_settings().log_level) 104 | -------------------------------------------------------------------------------- /app/core/database_session.py: -------------------------------------------------------------------------------- 1 | # SQLAlchemy async engine and sessions tools 2 | # 3 | # https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html 4 | # 5 | # for pool size configuration: 6 | # https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.Pool 7 | 8 | 9 | from sqlalchemy.engine.url import URL 10 | from sqlalchemy.ext.asyncio import ( 11 | AsyncEngine, 12 | AsyncSession, 13 | async_sessionmaker, 14 | create_async_engine, 15 | ) 16 | 17 | from app.core.config import get_settings 18 | 19 | 20 | def new_async_engine(uri: URL) -> AsyncEngine: 21 | return create_async_engine( 22 | uri, 23 | pool_pre_ping=True, 24 | pool_size=5, 25 | max_overflow=10, 26 | pool_timeout=30.0, 27 | pool_recycle=600, 28 | ) 29 | 30 | 31 | _ASYNC_ENGINE = new_async_engine(get_settings().sqlalchemy_database_uri) 32 | _ASYNC_SESSIONMAKER = async_sessionmaker(_ASYNC_ENGINE, expire_on_commit=False) 33 | 34 | 35 | def get_async_session() -> AsyncSession: # pragma: no cover 36 | return _ASYNC_SESSIONMAKER() 37 | -------------------------------------------------------------------------------- /app/core/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/core/security/__init__.py -------------------------------------------------------------------------------- /app/core/security/jwt.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import jwt 4 | from fastapi import HTTPException, status 5 | from pydantic import BaseModel 6 | 7 | from app.core.config import get_settings 8 | 9 | JWT_ALGORITHM = "HS256" 10 | 11 | 12 | # Payload follows RFC 7519 13 | # https://www.rfc-editor.org/rfc/rfc7519#section-4.1 14 | class JWTTokenPayload(BaseModel): 15 | iss: str 16 | sub: str 17 | exp: int 18 | iat: int 19 | 20 | 21 | class JWTToken(BaseModel): 22 | payload: JWTTokenPayload 23 | access_token: str 24 | 25 | 26 | def create_jwt_token(user_id: str) -> JWTToken: 27 | iat = int(time.time()) 28 | exp = iat + get_settings().security.jwt_access_token_expire_secs 29 | 30 | token_payload = JWTTokenPayload( 31 | iss=get_settings().security.jwt_issuer, 32 | sub=user_id, 33 | exp=exp, 34 | iat=iat, 35 | ) 36 | 37 | access_token = jwt.encode( 38 | token_payload.model_dump(), 39 | key=get_settings().security.jwt_secret_key.get_secret_value(), 40 | algorithm=JWT_ALGORITHM, 41 | ) 42 | 43 | return JWTToken(payload=token_payload, access_token=access_token) 44 | 45 | 46 | def verify_jwt_token(token: str) -> JWTTokenPayload: 47 | # Pay attention to verify_signature passed explicite, even if it is the default. 48 | # Verification is based on expected payload fields like "exp", "iat" etc. 49 | # so if you rename for example "exp" to "my_custom_exp", this is gonna break, 50 | # jwt.ExpiredSignatureError will not be raised, that can potentialy 51 | # be major security risk - not validating tokens at all. 52 | # If unsure, jump into jwt.decode code, make sure tests are passing 53 | # https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-hs256 54 | 55 | try: 56 | raw_payload = jwt.decode( 57 | token, 58 | get_settings().security.jwt_secret_key.get_secret_value(), 59 | algorithms=[JWT_ALGORITHM], 60 | options={"verify_signature": True}, 61 | issuer=get_settings().security.jwt_issuer, 62 | ) 63 | except jwt.InvalidTokenError as e: 64 | raise HTTPException( 65 | status_code=status.HTTP_401_UNAUTHORIZED, 66 | detail=f"Token invalid: {e}", 67 | ) 68 | 69 | return JWTTokenPayload(**raw_payload) 70 | -------------------------------------------------------------------------------- /app/core/security/password.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | 3 | from app.core.config import get_settings 4 | 5 | 6 | def verify_password(plain_password: str, hashed_password: str) -> bool: 7 | return bcrypt.checkpw( 8 | plain_password.encode("utf-8"), hashed_password.encode("utf-8") 9 | ) 10 | 11 | 12 | def get_password_hash(password: str) -> str: 13 | return bcrypt.hashpw( 14 | password.encode(), 15 | bcrypt.gensalt(get_settings().security.password_bcrypt_rounds), 16 | ).decode() 17 | 18 | 19 | DUMMY_PASSWORD = get_password_hash("") 20 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.middleware.trustedhost import TrustedHostMiddleware 4 | 5 | from app.api.api_router import api_router, auth_router 6 | from app.core.config import get_settings 7 | 8 | app = FastAPI( 9 | title="minimal fastapi postgres template", 10 | version="6.1.0", 11 | description="https://github.com/rafsaf/minimal-fastapi-postgres-template", 12 | openapi_url="/openapi.json", 13 | docs_url="/", 14 | ) 15 | 16 | app.include_router(auth_router) 17 | app.include_router(api_router) 18 | 19 | # Sets all CORS enabled origins 20 | app.add_middleware( 21 | CORSMiddleware, 22 | allow_origins=[ 23 | str(origin).rstrip("/") 24 | for origin in get_settings().security.backend_cors_origins 25 | ], 26 | allow_credentials=True, 27 | allow_methods=["*"], 28 | allow_headers=["*"], 29 | ) 30 | 31 | # Guards against HTTP Host Header attacks 32 | app.add_middleware( 33 | TrustedHostMiddleware, 34 | allowed_hosts=get_settings().security.allowed_hosts, 35 | ) 36 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | # SQL Alchemy models declaration. 2 | # https://docs.sqlalchemy.org/en/20/orm/quickstart.html#declare-models 3 | # mapped_column syntax from SQLAlchemy 2.0. 4 | 5 | # https://alembic.sqlalchemy.org/en/latest/tutorial.html 6 | # Note, it is used by alembic migrations logic, see `alembic/env.py` 7 | 8 | # Alembic shortcuts: 9 | # # create migration 10 | # alembic revision --autogenerate -m "migration_name" 11 | 12 | # # apply all migrations 13 | # alembic upgrade head 14 | 15 | 16 | import uuid 17 | from datetime import datetime 18 | 19 | from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Uuid, func 20 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 21 | 22 | 23 | class Base(DeclarativeBase): 24 | create_time: Mapped[datetime] = mapped_column( 25 | DateTime(timezone=True), server_default=func.now() 26 | ) 27 | update_time: Mapped[datetime] = mapped_column( 28 | DateTime(timezone=True), server_default=func.now(), onupdate=func.now() 29 | ) 30 | 31 | 32 | class User(Base): 33 | __tablename__ = "user_account" 34 | 35 | user_id: Mapped[str] = mapped_column( 36 | Uuid(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4()) 37 | ) 38 | email: Mapped[str] = mapped_column( 39 | String(256), nullable=False, unique=True, index=True 40 | ) 41 | hashed_password: Mapped[str] = mapped_column(String(128), nullable=False) 42 | refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user") 43 | 44 | 45 | class RefreshToken(Base): 46 | __tablename__ = "refresh_token" 47 | 48 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 49 | refresh_token: Mapped[str] = mapped_column( 50 | String(512), nullable=False, unique=True, index=True 51 | ) 52 | used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 53 | exp: Mapped[int] = mapped_column(BigInteger, nullable=False) 54 | user_id: Mapped[str] = mapped_column( 55 | ForeignKey("user_account.user_id", ondelete="CASCADE"), 56 | ) 57 | user: Mapped["User"] = relationship(back_populates="refresh_tokens") 58 | -------------------------------------------------------------------------------- /app/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/schemas/__init__.py -------------------------------------------------------------------------------- /app/schemas/requests.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr 2 | 3 | 4 | class BaseRequest(BaseModel): 5 | # may define additional fields or config shared across requests 6 | pass 7 | 8 | 9 | class RefreshTokenRequest(BaseRequest): 10 | refresh_token: str 11 | 12 | 13 | class UserUpdatePasswordRequest(BaseRequest): 14 | password: str 15 | 16 | 17 | class UserCreateRequest(BaseRequest): 18 | email: EmailStr 19 | password: str 20 | -------------------------------------------------------------------------------- /app/schemas/responses.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, EmailStr 2 | 3 | 4 | class BaseResponse(BaseModel): 5 | model_config = ConfigDict(from_attributes=True) 6 | 7 | 8 | class AccessTokenResponse(BaseResponse): 9 | token_type: str = "Bearer" 10 | access_token: str 11 | expires_at: int 12 | refresh_token: str 13 | refresh_token_expires_at: int 14 | 15 | 16 | class UserResponse(BaseResponse): 17 | user_id: str 18 | email: EmailStr 19 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/tests/__init__.py -------------------------------------------------------------------------------- /app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from collections.abc import AsyncGenerator 4 | 5 | import pytest 6 | import pytest_asyncio 7 | import sqlalchemy 8 | from httpx import ASGITransport, AsyncClient 9 | from sqlalchemy.ext.asyncio import ( 10 | AsyncSession, 11 | async_sessionmaker, 12 | ) 13 | 14 | from app.core import database_session 15 | from app.core.config import get_settings 16 | from app.core.security.jwt import create_jwt_token 17 | from app.core.security.password import get_password_hash 18 | from app.main import app as fastapi_app 19 | from app.models import Base, User 20 | 21 | default_user_id = "b75365d9-7bf9-4f54-add5-aeab333a087b" 22 | default_user_email = "geralt@wiedzmin.pl" 23 | default_user_password = "geralt" 24 | default_user_access_token = create_jwt_token(default_user_id).access_token 25 | 26 | 27 | @pytest_asyncio.fixture(scope="session", autouse=True) 28 | async def fixture_setup_new_test_database() -> None: 29 | worker_name = os.getenv("PYTEST_XDIST_WORKER", "gw0") 30 | test_db_name = f"test_db_{worker_name}" 31 | 32 | # create new test db using connection to current database 33 | conn = await database_session._ASYNC_ENGINE.connect() 34 | await conn.execution_options(isolation_level="AUTOCOMMIT") 35 | await conn.execute(sqlalchemy.text(f"DROP DATABASE IF EXISTS {test_db_name}")) 36 | await conn.execute(sqlalchemy.text(f"CREATE DATABASE {test_db_name}")) 37 | await conn.close() 38 | 39 | session_mpatch = pytest.MonkeyPatch() 40 | session_mpatch.setenv("DATABASE__DB", test_db_name) 41 | session_mpatch.setenv("SECURITY__PASSWORD_BCRYPT_ROUNDS", "4") 42 | 43 | # force settings to use now monkeypatched environments 44 | get_settings.cache_clear() 45 | 46 | # monkeypatch test database engine 47 | engine = database_session.new_async_engine(get_settings().sqlalchemy_database_uri) 48 | 49 | session_mpatch.setattr( 50 | database_session, 51 | "_ASYNC_ENGINE", 52 | engine, 53 | ) 54 | session_mpatch.setattr( 55 | database_session, 56 | "_ASYNC_SESSIONMAKER", 57 | async_sessionmaker(engine, expire_on_commit=False), 58 | ) 59 | 60 | # create app tables in test database 61 | async with engine.begin() as conn: 62 | await conn.run_sync(Base.metadata.create_all) 63 | 64 | 65 | @pytest_asyncio.fixture(scope="function", autouse=True) 66 | async def fixture_clean_get_settings_between_tests() -> AsyncGenerator[None]: 67 | yield 68 | 69 | get_settings.cache_clear() 70 | 71 | 72 | @pytest_asyncio.fixture(name="default_hashed_password", scope="session") 73 | async def fixture_default_hashed_password() -> str: 74 | return get_password_hash(default_user_password) 75 | 76 | 77 | @pytest_asyncio.fixture(name="session", scope="function") 78 | async def fixture_session_with_rollback( 79 | monkeypatch: pytest.MonkeyPatch, 80 | ) -> AsyncGenerator[AsyncSession]: 81 | # we want to monkeypatch get_async_session with one bound to session 82 | # that we will always rollback on function scope 83 | 84 | connection = await database_session._ASYNC_ENGINE.connect() 85 | transaction = await connection.begin() 86 | 87 | session = AsyncSession(bind=connection, expire_on_commit=False) 88 | 89 | monkeypatch.setattr( 90 | database_session, 91 | "get_async_session", 92 | lambda: session, 93 | ) 94 | 95 | yield session 96 | 97 | logging.critical("Rolling back transaction") 98 | await session.close() 99 | await transaction.rollback() 100 | await connection.close() 101 | 102 | 103 | @pytest_asyncio.fixture(name="client", scope="function") 104 | async def fixture_client(session: AsyncSession) -> AsyncGenerator[AsyncClient]: 105 | transport = ASGITransport(app=fastapi_app) 106 | async with AsyncClient(transport=transport, base_url="http://test") as aclient: 107 | aclient.headers.update({"Host": "localhost"}) 108 | yield aclient 109 | 110 | 111 | @pytest_asyncio.fixture(name="default_user", scope="function") 112 | async def fixture_default_user( 113 | session: AsyncSession, default_hashed_password: str 114 | ) -> AsyncGenerator[User]: 115 | default_user = User( 116 | user_id=default_user_id, 117 | email=default_user_email, 118 | hashed_password=default_hashed_password, 119 | ) 120 | session.add(default_user) 121 | 122 | await session.commit() 123 | await session.refresh(default_user) 124 | 125 | yield default_user 126 | 127 | 128 | @pytest_asyncio.fixture(name="default_user_headers", scope="function") 129 | async def fixture_default_user_headers(default_user: User) -> dict[str, str]: 130 | return {"Authorization": f"Bearer {default_user_access_token}"} 131 | -------------------------------------------------------------------------------- /app/tests/test_api_router_jwt_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import routing, status 3 | from freezegun import freeze_time 4 | from httpx import AsyncClient 5 | from sqlalchemy import delete 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from app.api import api_messages 9 | from app.api.api_router import api_router 10 | from app.core.security.jwt import create_jwt_token 11 | from app.models import User 12 | 13 | 14 | @pytest.mark.asyncio(loop_scope="session") 15 | @pytest.mark.parametrize("api_route", api_router.routes) 16 | async def test_api_routes_raise_401_on_jwt_decode_errors( 17 | client: AsyncClient, 18 | api_route: routing.APIRoute, 19 | ) -> None: 20 | for method in api_route.methods: 21 | response = await client.request( 22 | method=method, 23 | url=api_route.path, 24 | headers={"Authorization": "Bearer garbage-invalid-jwt"}, 25 | ) 26 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 27 | assert response.json() == {"detail": "Token invalid: Not enough segments"} 28 | 29 | 30 | @pytest.mark.asyncio(loop_scope="session") 31 | @pytest.mark.parametrize("api_route", api_router.routes) 32 | async def test_api_routes_raise_401_on_jwt_expired_token( 33 | client: AsyncClient, 34 | default_user: User, 35 | api_route: routing.APIRoute, 36 | ) -> None: 37 | with freeze_time("2023-01-01"): 38 | jwt = create_jwt_token(default_user.user_id) 39 | with freeze_time("2023-02-01"): 40 | for method in api_route.methods: 41 | response = await client.request( 42 | method=method, 43 | url=api_route.path, 44 | headers={"Authorization": f"Bearer {jwt.access_token}"}, 45 | ) 46 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 47 | assert response.json() == {"detail": "Token invalid: Signature has expired"} 48 | 49 | 50 | @pytest.mark.asyncio(loop_scope="session") 51 | @pytest.mark.parametrize("api_route", api_router.routes) 52 | async def test_api_routes_raise_401_on_jwt_user_deleted( 53 | client: AsyncClient, 54 | default_user_headers: dict[str, str], 55 | default_user: User, 56 | api_route: routing.APIRoute, 57 | session: AsyncSession, 58 | ) -> None: 59 | await session.execute(delete(User).where(User.user_id == default_user.user_id)) 60 | await session.commit() 61 | 62 | for method in api_route.methods: 63 | response = await client.request( 64 | method=method, 65 | url=api_route.path, 66 | headers=default_user_headers, 67 | ) 68 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 69 | assert response.json() == {"detail": api_messages.JWT_ERROR_USER_REMOVED} 70 | -------------------------------------------------------------------------------- /app/tests/test_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/tests/test_auth/__init__.py -------------------------------------------------------------------------------- /app/tests/test_auth/test_access_token.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | from fastapi import status 5 | from freezegun import freeze_time 6 | from httpx import AsyncClient 7 | from sqlalchemy import func, select 8 | from sqlalchemy.ext.asyncio import AsyncSession 9 | 10 | from app.api import api_messages 11 | from app.core.config import get_settings 12 | from app.core.security.jwt import verify_jwt_token 13 | from app.main import app 14 | from app.models import RefreshToken, User 15 | from app.tests.conftest import default_user_password 16 | 17 | 18 | @pytest.mark.asyncio(loop_scope="session") 19 | async def test_login_access_token_has_response_status_code( 20 | client: AsyncClient, 21 | default_user: User, 22 | ) -> None: 23 | response = await client.post( 24 | app.url_path_for("login_access_token"), 25 | data={ 26 | "username": default_user.email, 27 | "password": default_user_password, 28 | }, 29 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 30 | ) 31 | 32 | assert response.status_code == status.HTTP_200_OK 33 | 34 | 35 | @pytest.mark.asyncio(loop_scope="session") 36 | async def test_login_access_token_jwt_has_valid_token_type( 37 | client: AsyncClient, 38 | default_user: User, 39 | ) -> None: 40 | response = await client.post( 41 | app.url_path_for("login_access_token"), 42 | data={ 43 | "username": default_user.email, 44 | "password": default_user_password, 45 | }, 46 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 47 | ) 48 | 49 | token = response.json() 50 | assert token["token_type"] == "Bearer" 51 | 52 | 53 | @pytest.mark.asyncio(loop_scope="session") 54 | @freeze_time("2023-01-01") 55 | async def test_login_access_token_jwt_has_valid_expire_time( 56 | client: AsyncClient, 57 | default_user: User, 58 | ) -> None: 59 | response = await client.post( 60 | app.url_path_for("login_access_token"), 61 | data={ 62 | "username": default_user.email, 63 | "password": default_user_password, 64 | }, 65 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 66 | ) 67 | 68 | token = response.json() 69 | current_timestamp = int(time.time()) 70 | assert ( 71 | token["expires_at"] 72 | == current_timestamp + get_settings().security.jwt_access_token_expire_secs 73 | ) 74 | 75 | 76 | @pytest.mark.asyncio(loop_scope="session") 77 | @freeze_time("2023-01-01") 78 | async def test_login_access_token_returns_valid_jwt_access_token( 79 | client: AsyncClient, 80 | default_user: User, 81 | ) -> None: 82 | response = await client.post( 83 | app.url_path_for("login_access_token"), 84 | data={ 85 | "username": default_user.email, 86 | "password": default_user_password, 87 | }, 88 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 89 | ) 90 | 91 | now = int(time.time()) 92 | token = response.json() 93 | token_payload = verify_jwt_token(token["access_token"]) 94 | 95 | assert token_payload.sub == default_user.user_id 96 | assert token_payload.iat == now 97 | assert token_payload.exp == token["expires_at"] 98 | 99 | 100 | @pytest.mark.asyncio(loop_scope="session") 101 | async def test_login_access_token_refresh_token_has_valid_expire_time( 102 | client: AsyncClient, 103 | default_user: User, 104 | ) -> None: 105 | response = await client.post( 106 | app.url_path_for("login_access_token"), 107 | data={ 108 | "username": default_user.email, 109 | "password": default_user_password, 110 | }, 111 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 112 | ) 113 | 114 | token = response.json() 115 | current_time = int(time.time()) 116 | assert ( 117 | token["refresh_token_expires_at"] 118 | == current_time + get_settings().security.refresh_token_expire_secs 119 | ) 120 | 121 | 122 | @pytest.mark.asyncio(loop_scope="session") 123 | async def test_login_access_token_refresh_token_exists_in_db( 124 | client: AsyncClient, 125 | default_user: User, 126 | session: AsyncSession, 127 | ) -> None: 128 | response = await client.post( 129 | app.url_path_for("login_access_token"), 130 | data={ 131 | "username": default_user.email, 132 | "password": default_user_password, 133 | }, 134 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 135 | ) 136 | 137 | token = response.json() 138 | 139 | token_db_count = await session.scalar( 140 | select(func.count()).where(RefreshToken.refresh_token == token["refresh_token"]) 141 | ) 142 | assert token_db_count == 1 143 | 144 | 145 | @pytest.mark.asyncio(loop_scope="session") 146 | async def test_login_access_token_refresh_token_in_db_has_valid_fields( 147 | client: AsyncClient, 148 | default_user: User, 149 | session: AsyncSession, 150 | ) -> None: 151 | response = await client.post( 152 | app.url_path_for("login_access_token"), 153 | data={ 154 | "username": default_user.email, 155 | "password": default_user_password, 156 | }, 157 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 158 | ) 159 | 160 | token = response.json() 161 | result = await session.scalars( 162 | select(RefreshToken).where(RefreshToken.refresh_token == token["refresh_token"]) 163 | ) 164 | refresh_token = result.one() 165 | 166 | assert refresh_token.user_id == default_user.user_id 167 | assert refresh_token.exp == token["refresh_token_expires_at"] 168 | assert not refresh_token.used 169 | 170 | 171 | @pytest.mark.asyncio(loop_scope="session") 172 | async def test_auth_access_token_fail_for_not_existing_user_with_message( 173 | client: AsyncClient, 174 | ) -> None: 175 | response = await client.post( 176 | app.url_path_for("login_access_token"), 177 | data={ 178 | "username": "non-existing", 179 | "password": "bla", 180 | }, 181 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 182 | ) 183 | 184 | assert response.status_code == status.HTTP_400_BAD_REQUEST 185 | assert response.json() == {"detail": api_messages.PASSWORD_INVALID} 186 | 187 | 188 | @pytest.mark.asyncio(loop_scope="session") 189 | async def test_auth_access_token_fail_for_invalid_password_with_message( 190 | client: AsyncClient, 191 | default_user: User, 192 | ) -> None: 193 | response = await client.post( 194 | app.url_path_for("login_access_token"), 195 | data={ 196 | "username": default_user.email, 197 | "password": "invalid", 198 | }, 199 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 200 | ) 201 | 202 | assert response.status_code == status.HTTP_400_BAD_REQUEST 203 | assert response.json() == {"detail": api_messages.PASSWORD_INVALID} 204 | -------------------------------------------------------------------------------- /app/tests/test_auth/test_auth_refresh_token.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | from fastapi import status 5 | from freezegun import freeze_time 6 | from httpx import AsyncClient 7 | from sqlalchemy import func, select 8 | from sqlalchemy.ext.asyncio import AsyncSession 9 | 10 | from app.api import api_messages 11 | from app.core.config import get_settings 12 | from app.core.security.jwt import verify_jwt_token 13 | from app.main import app 14 | from app.models import RefreshToken, User 15 | 16 | 17 | @pytest.mark.asyncio(loop_scope="session") 18 | async def test_refresh_token_fails_with_message_when_token_does_not_exist( 19 | client: AsyncClient, 20 | ) -> None: 21 | response = await client.post( 22 | app.url_path_for("refresh_token"), 23 | json={ 24 | "refresh_token": "blaxx", 25 | }, 26 | ) 27 | 28 | assert response.status_code == status.HTTP_404_NOT_FOUND 29 | assert response.json() == {"detail": api_messages.REFRESH_TOKEN_NOT_FOUND} 30 | 31 | 32 | @pytest.mark.asyncio(loop_scope="session") 33 | async def test_refresh_token_fails_with_message_when_token_is_expired( 34 | client: AsyncClient, 35 | default_user: User, 36 | session: AsyncSession, 37 | ) -> None: 38 | test_refresh_token = RefreshToken( 39 | user_id=default_user.user_id, 40 | refresh_token="blaxx", 41 | exp=int(time.time()) - 1, 42 | ) 43 | session.add(test_refresh_token) 44 | await session.commit() 45 | 46 | response = await client.post( 47 | app.url_path_for("refresh_token"), 48 | json={ 49 | "refresh_token": "blaxx", 50 | }, 51 | ) 52 | 53 | assert response.status_code == status.HTTP_400_BAD_REQUEST 54 | assert response.json() == {"detail": api_messages.REFRESH_TOKEN_EXPIRED} 55 | 56 | 57 | @pytest.mark.asyncio(loop_scope="session") 58 | async def test_refresh_token_fails_with_message_when_token_is_used( 59 | client: AsyncClient, 60 | default_user: User, 61 | session: AsyncSession, 62 | ) -> None: 63 | test_refresh_token = RefreshToken( 64 | user_id=default_user.user_id, 65 | refresh_token="blaxx", 66 | exp=int(time.time()) + 1000, 67 | used=True, 68 | ) 69 | session.add(test_refresh_token) 70 | await session.commit() 71 | 72 | response = await client.post( 73 | app.url_path_for("refresh_token"), 74 | json={ 75 | "refresh_token": "blaxx", 76 | }, 77 | ) 78 | 79 | assert response.status_code == status.HTTP_400_BAD_REQUEST 80 | assert response.json() == {"detail": api_messages.REFRESH_TOKEN_ALREADY_USED} 81 | 82 | 83 | @pytest.mark.asyncio(loop_scope="session") 84 | async def test_refresh_token_success_response_status_code( 85 | client: AsyncClient, 86 | default_user: User, 87 | session: AsyncSession, 88 | ) -> None: 89 | test_refresh_token = RefreshToken( 90 | user_id=default_user.user_id, 91 | refresh_token="blaxx", 92 | exp=int(time.time()) + 1000, 93 | used=False, 94 | ) 95 | session.add(test_refresh_token) 96 | await session.commit() 97 | 98 | response = await client.post( 99 | app.url_path_for("refresh_token"), 100 | json={ 101 | "refresh_token": "blaxx", 102 | }, 103 | ) 104 | 105 | assert response.status_code == status.HTTP_200_OK 106 | 107 | 108 | @pytest.mark.asyncio(loop_scope="session") 109 | async def test_refresh_token_success_old_token_is_used( 110 | client: AsyncClient, 111 | default_user: User, 112 | session: AsyncSession, 113 | ) -> None: 114 | test_refresh_token = RefreshToken( 115 | user_id=default_user.user_id, 116 | refresh_token="blaxx", 117 | exp=int(time.time()) + 1000, 118 | used=False, 119 | ) 120 | session.add(test_refresh_token) 121 | await session.commit() 122 | 123 | await client.post( 124 | app.url_path_for("refresh_token"), 125 | json={ 126 | "refresh_token": "blaxx", 127 | }, 128 | ) 129 | 130 | used_test_refresh_token = await session.scalar( 131 | select(RefreshToken).where(RefreshToken.refresh_token == "blaxx") 132 | ) 133 | assert used_test_refresh_token is not None 134 | assert used_test_refresh_token.used 135 | 136 | 137 | @pytest.mark.asyncio(loop_scope="session") 138 | async def test_refresh_token_success_jwt_has_valid_token_type( 139 | client: AsyncClient, 140 | default_user: User, 141 | session: AsyncSession, 142 | ) -> None: 143 | test_refresh_token = RefreshToken( 144 | user_id=default_user.user_id, 145 | refresh_token="blaxx", 146 | exp=int(time.time()) + 1000, 147 | used=False, 148 | ) 149 | session.add(test_refresh_token) 150 | await session.commit() 151 | 152 | response = await client.post( 153 | app.url_path_for("refresh_token"), 154 | json={ 155 | "refresh_token": "blaxx", 156 | }, 157 | ) 158 | 159 | token = response.json() 160 | assert token["token_type"] == "Bearer" 161 | 162 | 163 | @pytest.mark.asyncio(loop_scope="session") 164 | @freeze_time("2023-01-01") 165 | async def test_refresh_token_success_jwt_has_valid_expire_time( 166 | client: AsyncClient, 167 | default_user: User, 168 | session: AsyncSession, 169 | ) -> None: 170 | test_refresh_token = RefreshToken( 171 | user_id=default_user.user_id, 172 | refresh_token="blaxx", 173 | exp=int(time.time()) + 1000, 174 | used=False, 175 | ) 176 | session.add(test_refresh_token) 177 | await session.commit() 178 | 179 | response = await client.post( 180 | app.url_path_for("refresh_token"), 181 | json={ 182 | "refresh_token": "blaxx", 183 | }, 184 | ) 185 | 186 | token = response.json() 187 | current_timestamp = int(time.time()) 188 | assert ( 189 | token["expires_at"] 190 | == current_timestamp + get_settings().security.jwt_access_token_expire_secs 191 | ) 192 | 193 | 194 | @pytest.mark.asyncio(loop_scope="session") 195 | @freeze_time("2023-01-01") 196 | async def test_refresh_token_success_jwt_has_valid_access_token( 197 | client: AsyncClient, 198 | default_user: User, 199 | session: AsyncSession, 200 | ) -> None: 201 | test_refresh_token = RefreshToken( 202 | user_id=default_user.user_id, 203 | refresh_token="blaxx", 204 | exp=int(time.time()) + 1000, 205 | used=False, 206 | ) 207 | session.add(test_refresh_token) 208 | await session.commit() 209 | 210 | response = await client.post( 211 | app.url_path_for("refresh_token"), 212 | json={ 213 | "refresh_token": "blaxx", 214 | }, 215 | ) 216 | 217 | now = int(time.time()) 218 | token = response.json() 219 | token_payload = verify_jwt_token(token["access_token"]) 220 | 221 | assert token_payload.sub == default_user.user_id 222 | assert token_payload.iat == now 223 | assert token_payload.exp == token["expires_at"] 224 | 225 | 226 | @pytest.mark.asyncio(loop_scope="session") 227 | @freeze_time("2023-01-01") 228 | async def test_refresh_token_success_refresh_token_has_valid_expire_time( 229 | client: AsyncClient, 230 | default_user: User, 231 | session: AsyncSession, 232 | ) -> None: 233 | test_refresh_token = RefreshToken( 234 | user_id=default_user.user_id, 235 | refresh_token="blaxx", 236 | exp=int(time.time()) + 1000, 237 | used=False, 238 | ) 239 | session.add(test_refresh_token) 240 | await session.commit() 241 | 242 | response = await client.post( 243 | app.url_path_for("refresh_token"), 244 | json={ 245 | "refresh_token": "blaxx", 246 | }, 247 | ) 248 | 249 | token = response.json() 250 | current_time = int(time.time()) 251 | assert ( 252 | token["refresh_token_expires_at"] 253 | == current_time + get_settings().security.refresh_token_expire_secs 254 | ) 255 | 256 | 257 | @pytest.mark.asyncio(loop_scope="session") 258 | async def test_refresh_token_success_new_refresh_token_is_in_db( 259 | client: AsyncClient, 260 | default_user: User, 261 | session: AsyncSession, 262 | ) -> None: 263 | test_refresh_token = RefreshToken( 264 | user_id=default_user.user_id, 265 | refresh_token="blaxx", 266 | exp=int(time.time()) + 1000, 267 | used=False, 268 | ) 269 | session.add(test_refresh_token) 270 | await session.commit() 271 | 272 | response = await client.post( 273 | app.url_path_for("refresh_token"), 274 | json={ 275 | "refresh_token": "blaxx", 276 | }, 277 | ) 278 | 279 | token = response.json() 280 | token_db_count = await session.scalar( 281 | select(func.count()).where(RefreshToken.refresh_token == token["refresh_token"]) 282 | ) 283 | assert token_db_count == 1 284 | -------------------------------------------------------------------------------- /app/tests/test_auth/test_register_new_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import AsyncClient 4 | from sqlalchemy import func, select 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from app.api import api_messages 8 | from app.main import app 9 | from app.models import User 10 | 11 | 12 | @pytest.mark.asyncio(loop_scope="session") 13 | async def test_register_new_user_status_code( 14 | client: AsyncClient, 15 | ) -> None: 16 | response = await client.post( 17 | app.url_path_for("register_new_user"), 18 | json={ 19 | "email": "test@email.com", 20 | "password": "testtesttest", 21 | }, 22 | ) 23 | 24 | assert response.status_code == status.HTTP_201_CREATED 25 | 26 | 27 | @pytest.mark.asyncio(loop_scope="session") 28 | async def test_register_new_user_creates_record_in_db( 29 | client: AsyncClient, 30 | session: AsyncSession, 31 | ) -> None: 32 | await client.post( 33 | app.url_path_for("register_new_user"), 34 | json={ 35 | "email": "test@email.com", 36 | "password": "testtesttest", 37 | }, 38 | ) 39 | 40 | user_count = await session.scalar( 41 | select(func.count()).where(User.email == "test@email.com") 42 | ) 43 | assert user_count == 1 44 | 45 | 46 | @pytest.mark.asyncio(loop_scope="session") 47 | async def test_register_new_user_cannot_create_already_created_user( 48 | client: AsyncClient, 49 | session: AsyncSession, 50 | ) -> None: 51 | user = User( 52 | email="test@email.com", 53 | hashed_password="bla", 54 | ) 55 | session.add(user) 56 | await session.commit() 57 | 58 | response = await client.post( 59 | app.url_path_for("register_new_user"), 60 | json={ 61 | "email": "test@email.com", 62 | "password": "testtesttest", 63 | }, 64 | ) 65 | 66 | assert response.status_code == status.HTTP_400_BAD_REQUEST 67 | assert response.json() == {"detail": api_messages.EMAIL_ADDRESS_ALREADY_USED} 68 | -------------------------------------------------------------------------------- /app/tests/test_core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/tests/test_core/__init__.py -------------------------------------------------------------------------------- /app/tests/test_core/test_jwt.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | from fastapi import HTTPException 5 | from freezegun import freeze_time 6 | from pydantic import SecretStr 7 | 8 | from app.core.config import get_settings 9 | from app.core.security import jwt 10 | 11 | 12 | def test_jwt_access_token_can_be_decoded_back_into_user_id() -> None: 13 | user_id = "test_user_id" 14 | token = jwt.create_jwt_token(user_id) 15 | 16 | payload = jwt.verify_jwt_token(token=token.access_token) 17 | assert payload.sub == user_id 18 | 19 | 20 | @freeze_time("2024-01-01") 21 | def test_jwt_payload_is_correct() -> None: 22 | user_id = "test_user_id" 23 | token = jwt.create_jwt_token(user_id) 24 | 25 | assert token.payload.iat == int(time.time()) 26 | assert token.payload.sub == user_id 27 | assert token.payload.iss == get_settings().security.jwt_issuer 28 | assert ( 29 | token.payload.exp 30 | == int(time.time()) + get_settings().security.jwt_access_token_expire_secs 31 | ) 32 | 33 | 34 | def test_jwt_error_after_exp_time() -> None: 35 | user_id = "test_user_id" 36 | with freeze_time("2024-01-01"): 37 | token = jwt.create_jwt_token(user_id) 38 | with freeze_time("2024-02-01"): 39 | with pytest.raises(HTTPException) as e: 40 | jwt.verify_jwt_token(token=token.access_token) 41 | 42 | assert e.value.detail == "Token invalid: Signature has expired" 43 | 44 | 45 | def test_jwt_error_before_iat_time() -> None: 46 | user_id = "test_user_id" 47 | with freeze_time("2024-01-01"): 48 | token = jwt.create_jwt_token(user_id) 49 | with freeze_time("2023-12-01"): 50 | with pytest.raises(HTTPException) as e: 51 | jwt.verify_jwt_token(token=token.access_token) 52 | 53 | assert e.value.detail == "Token invalid: The token is not yet valid (iat)" 54 | 55 | 56 | def test_jwt_error_with_invalid_token() -> None: 57 | with pytest.raises(HTTPException) as e: 58 | jwt.verify_jwt_token(token="invalid!") 59 | 60 | assert e.value.detail == "Token invalid: Not enough segments" 61 | 62 | 63 | def test_jwt_error_with_invalid_issuer() -> None: 64 | user_id = "test_user_id" 65 | token = jwt.create_jwt_token(user_id) 66 | 67 | get_settings().security.jwt_issuer = "another_issuer" 68 | 69 | with pytest.raises(HTTPException) as e: 70 | jwt.verify_jwt_token(token=token.access_token) 71 | 72 | assert e.value.detail == "Token invalid: Invalid issuer" 73 | 74 | 75 | def test_jwt_error_with_invalid_secret_key() -> None: 76 | user_id = "test_user_id" 77 | token = jwt.create_jwt_token(user_id) 78 | 79 | get_settings().security.jwt_secret_key = SecretStr("the secret has changed now!") 80 | 81 | with pytest.raises(HTTPException) as e: 82 | jwt.verify_jwt_token(token=token.access_token) 83 | 84 | assert e.value.detail == "Token invalid: Signature verification failed" 85 | -------------------------------------------------------------------------------- /app/tests/test_core/test_password.py: -------------------------------------------------------------------------------- 1 | from app.core.security.password import get_password_hash, verify_password 2 | 3 | 4 | def test_hashed_password_is_verified() -> None: 5 | pwd_hash = get_password_hash("my_password") 6 | assert verify_password("my_password", pwd_hash) 7 | 8 | 9 | def test_invalid_password_is_not_verified() -> None: 10 | pwd_hash = get_password_hash("my_password") 11 | assert not verify_password("my_password_invalid", pwd_hash) 12 | -------------------------------------------------------------------------------- /app/tests/test_users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafsaf/minimal-fastapi-postgres-template/92b9a5cfdca1d349a0ec3d4db5f79ec69698b762/app/tests/test_users/__init__.py -------------------------------------------------------------------------------- /app/tests/test_users/test_delete_current_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import AsyncClient 4 | from sqlalchemy import select 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from app.main import app 8 | from app.models import User 9 | 10 | 11 | @pytest.mark.asyncio(loop_scope="session") 12 | async def test_delete_current_user_status_code( 13 | client: AsyncClient, 14 | default_user_headers: dict[str, str], 15 | ) -> None: 16 | response = await client.delete( 17 | app.url_path_for("delete_current_user"), 18 | headers=default_user_headers, 19 | ) 20 | 21 | assert response.status_code == status.HTTP_204_NO_CONTENT 22 | 23 | 24 | @pytest.mark.asyncio(loop_scope="session") 25 | async def test_delete_current_user_is_deleted_in_db( 26 | client: AsyncClient, 27 | default_user_headers: dict[str, str], 28 | default_user: User, 29 | session: AsyncSession, 30 | ) -> None: 31 | await client.delete( 32 | app.url_path_for("delete_current_user"), 33 | headers=default_user_headers, 34 | ) 35 | 36 | user = await session.scalar( 37 | select(User).where(User.user_id == default_user.user_id) 38 | ) 39 | assert user is None 40 | -------------------------------------------------------------------------------- /app/tests/test_users/test_read_current_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import AsyncClient 4 | 5 | from app.main import app 6 | from app.tests.conftest import ( 7 | default_user_email, 8 | default_user_id, 9 | ) 10 | 11 | 12 | @pytest.mark.asyncio(loop_scope="session") 13 | async def test_read_current_user_status_code( 14 | client: AsyncClient, default_user_headers: dict[str, str] 15 | ) -> None: 16 | response = await client.get( 17 | app.url_path_for("read_current_user"), 18 | headers=default_user_headers, 19 | ) 20 | 21 | assert response.status_code == status.HTTP_200_OK 22 | 23 | 24 | @pytest.mark.asyncio(loop_scope="session") 25 | async def test_read_current_user_response( 26 | client: AsyncClient, default_user_headers: dict[str, str] 27 | ) -> None: 28 | response = await client.get( 29 | app.url_path_for("read_current_user"), 30 | headers=default_user_headers, 31 | ) 32 | 33 | assert response.json() == { 34 | "user_id": default_user_id, 35 | "email": default_user_email, 36 | } 37 | -------------------------------------------------------------------------------- /app/tests/test_users/test_reset_password.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import status 3 | from httpx import AsyncClient 4 | from sqlalchemy import select 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from app.core.security.password import verify_password 8 | from app.main import app 9 | from app.models import User 10 | 11 | 12 | @pytest.mark.asyncio(loop_scope="session") 13 | async def test_reset_current_user_password_status_code( 14 | client: AsyncClient, 15 | default_user_headers: dict[str, str], 16 | ) -> None: 17 | response = await client.post( 18 | app.url_path_for("reset_current_user_password"), 19 | headers=default_user_headers, 20 | json={"password": "test_pwd"}, 21 | ) 22 | 23 | assert response.status_code == status.HTTP_204_NO_CONTENT 24 | 25 | 26 | @pytest.mark.asyncio(loop_scope="session") 27 | async def test_reset_current_user_password_is_changed_in_db( 28 | client: AsyncClient, 29 | default_user_headers: dict[str, str], 30 | default_user: User, 31 | session: AsyncSession, 32 | ) -> None: 33 | await client.post( 34 | app.url_path_for("reset_current_user_password"), 35 | headers=default_user_headers, 36 | json={"password": "test_pwd"}, 37 | ) 38 | 39 | user = await session.scalar( 40 | select(User).where(User.user_id == default_user.user_id) 41 | ) 42 | assert user is not None 43 | assert verify_password("test_pwd", user.hashed_password) 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # For local development, only database is running 2 | # 3 | # docker compose up -d 4 | # uvicorn app.main:app --reload 5 | # 6 | 7 | services: 8 | postgres_db: 9 | restart: unless-stopped 10 | image: postgres:17 11 | volumes: 12 | - postgres_db:/var/lib/postgresql/data 13 | environment: 14 | - POSTGRES_DB=${DATABASE__DB} 15 | - POSTGRES_USER=${DATABASE__USERNAME} 16 | - POSTGRES_PASSWORD=${DATABASE__PASSWORD} 17 | env_file: 18 | - .env 19 | ports: 20 | - "${DATABASE__PORT}:5432" 21 | 22 | volumes: 23 | postgres_db: 24 | -------------------------------------------------------------------------------- /init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Run migrations" 5 | alembic upgrade head 6 | 7 | # Run whatever CMD was passed 8 | exec "$@" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["admin "] 3 | description = "FastAPI project generated using minimal-fastapi-postgres-template." 4 | name = "app" 5 | version = "0.1.0-alpha" 6 | package-mode = false 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.13" 10 | 11 | alembic = "^1.16.1" 12 | asyncpg = "^0.30.0" 13 | bcrypt = "^4.3.0" 14 | fastapi = "^0.115.12" 15 | pydantic = { extras = ["dotenv", "email"], version = "^2.11.5" } 16 | pydantic-settings = "^2.9.1" 17 | pyjwt = "^2.10.1" 18 | python-multipart = "^0.0.20" 19 | sqlalchemy = { extras = ["asyncio"], version = "^2.0.41" } 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | coverage = "^7.8.2" 23 | freezegun = "^1.5.2" 24 | greenlet = "^3.2.2" 25 | httpx = "^0.28.1" 26 | mypy = "^1.16.0" 27 | pre-commit = "^4.2.0" 28 | pytest = "^8.4.0" 29 | pytest-asyncio = "^1.0.0" 30 | pytest-cov = "^6.1.1" 31 | pytest-xdist = "^3.7.0" 32 | ruff = "^0.11.12" 33 | uvicorn = { extras = ["standard"], version = "^0.34.3" } 34 | 35 | [build-system] 36 | build-backend = "poetry.core.masonry.api" 37 | requires = ["poetry-core>=1.0.0"] 38 | 39 | [tool.pytest.ini_options] 40 | addopts = "-vv -n auto --cov --cov-report xml --cov-report term-missing --cov-fail-under=100" 41 | asyncio_default_fixture_loop_scope = "session" 42 | asyncio_mode = "auto" 43 | testpaths = ["app/tests"] 44 | 45 | [tool.coverage.run] 46 | concurrency = ["greenlet"] 47 | omit = ["app/tests/*"] 48 | source = ["app"] 49 | 50 | [tool.mypy] 51 | python_version = "3.13" 52 | strict = true 53 | 54 | [tool.ruff] 55 | target-version = "py313" 56 | 57 | [tool.ruff.lint] 58 | # pycodestyle, pyflakes, isort, pylint, pyupgrade 59 | ignore = ["E501"] 60 | select = ["E", "F", "I", "PL", "UP", "W"] 61 | --------------------------------------------------------------------------------