main 9048b6d3f2c9 cached
50 files
85.2 KB
21.9k tokens
92 symbols
1 requests
Download .txt
Repository: rafsaf/minimal-fastapi-postgres-template
Branch: main
Commit: 9048b6d3f2c9
Files: 50
Total size: 85.2 KB

Directory structure:
gitextract_d8hu4kk5/

├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── dev_build.yml
│       ├── tests.yml
│       └── type_check.yml
├── .gitignore
├── .pre-commit-config.yaml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── alembic/
│   ├── README
│   ├── env.py
│   ├── script.py.mako
│   └── versions/
│       └── 20260203_1616_initial_auth_683275eeb305.py
├── alembic.ini
├── app/
│   ├── __init__.py
│   ├── auth/
│   │   ├── api_messages.py
│   │   ├── dependencies.py
│   │   ├── jwt.py
│   │   ├── models.py
│   │   ├── password.py
│   │   ├── responses.py
│   │   ├── schemas.py
│   │   ├── tests/
│   │   │   ├── __init__.py
│   │   │   ├── test_jwt.py
│   │   │   ├── test_password.py
│   │   │   ├── test_view_delete_current_user.py
│   │   │   ├── test_view_login_access_token.py
│   │   │   ├── test_view_read_current_user.py
│   │   │   ├── test_view_refresh_token.py
│   │   │   ├── test_view_register_new_user.py
│   │   │   └── test_view_reset_current_user_password.py
│   │   └── views.py
│   ├── conftest.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── database_session.py
│   │   ├── lifespan.py
│   │   ├── metrics.py
│   │   └── models.py
│   ├── main.py
│   ├── probe/
│   │   ├── __init__.py
│   │   ├── tests/
│   │   │   ├── __init__.py
│   │   │   └── test_views.py
│   │   └── views.py
│   └── tests/
│       ├── auth.py
│       └── factories.py
├── docker-compose.yml
├── init.sh
└── pyproject.toml

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: uv
    directory: /
    schedule:
      interval: monthly
    open-pull-requests-limit: 1
    allow:
      - dependency-type: "all"
    groups:
      all-dependencies:
        patterns:
          - "*"

  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: monthly

  - package-ecosystem: docker
    directory: /
    schedule:
      interval: monthly


================================================
FILE: .github/workflows/dev_build.yml
================================================
name: dev-build
on:
  workflow_run:
    workflows: ["tests"]
    branches: [main]
    types:
      - completed

  workflow_dispatch:
    inputs:
      tag:
        description: "Docker image tag"
        required: true
        default: "latest"

env:
  IMAGE_TAG: ${{ github.event.inputs.tag || 'latest' }}

jobs:
  dev_build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USER }}
          password: ${{ secrets.DOCKER_PASS }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          file: Dockerfile
          push: true
          tags: rafsaf/minimal-fastapi-postgres-template:${{ env.IMAGE_TAG }}


================================================
FILE: .github/workflows/tests.yml
================================================
name: tests
on:
  push:
    branches:
      - "**"
    tags-ignore:
      - "*.*"

jobs:
  tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v6

      - name: "Set up Python"
        uses: actions/setup-python@v6
        with:
          python-version-file: "pyproject.toml"

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          version: "0.9.2"
          enable-cache: true

      - name: Install the project
        run: uv sync --locked --all-extras --dev
        shell: bash

      - name: Run tests
        env:
          SECURITY__JWT_SECRET_KEY: very-not-secret
          DATABASE__HOSTNAME: localhost
          DATABASE__PASSWORD: postgres
        run: |
          uv run pytest


================================================
FILE: .github/workflows/type_check.yml
================================================
name: type-check
on:
  push:
    branches:
      - "**"
    tags-ignore:
      - "*.*"

jobs:
  type_check:
    strategy:
      matrix:
        check: ["ruff check", "mypy --check", "ruff format --check"]

    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: "Set up Python"
        uses: actions/setup-python@v6
        with:
          python-version-file: "pyproject.toml"

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          version: "0.9.2"
          enable-cache: true

      - name: Install the project
        run: uv sync --locked --all-extras --dev
        shell: bash

      - name: Run ${{ matrix.check }}
        run: |
          uv run ${{ matrix.check }} .


================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
.env

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
log.txt

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments

.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# ruff
.ruff_cache

# Pyre type checker
.pyre/

================================================
FILE: .pre-commit-config.yaml
================================================
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: check-yaml

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.0
    hooks:
      - id: ruff-format

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.0
    hooks:
      - id: ruff-check
        args: [--fix]


================================================
FILE: Dockerfile
================================================
FROM python:3.14-slim-trixie AS base

ENV PYTHONUNBUFFERED=1
WORKDIR /build

# Create requirements.txt file
FROM base AS uv
COPY --from=ghcr.io/astral-sh/uv:0.9.2 /uv /uvx /bin/
COPY uv.lock pyproject.toml ./
RUN uv export --no-dev --no-hashes -o /requirements.txt --no-install-workspace --frozen
RUN uv export --only-group dev --no-hashes -o /requirements-dev.txt --no-install-workspace --frozen

FROM base AS final
COPY --from=uv /requirements.txt .

# Create venv, add it to path and install requirements
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
RUN pip install -r requirements.txt

# Install uvicorn server
RUN pip install uvicorn[standard]

# Copy the rest of app
COPY app app
COPY alembic alembic
COPY alembic.ini .
COPY pyproject.toml .
COPY init.sh .

# Expose port 8000 for app and optional 9090 for prometheus metrics
EXPOSE 8000
EXPOSE 9090

# Make the init script executable
RUN chmod +x ./init.sh

# Set ENTRYPOINT to always run init.sh
ENTRYPOINT ["./init.sh"]

# Set CMD to uvicorn
# /venv/bin/uvicorn is used because from entrypoint script PATH is new
CMD ["/venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--loop", "uvloop"]

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 rafsaf

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: Makefile
================================================
BIND_PORT ?= 8000
BIND_HOST ?= localhost

.PHONY: help
help:  ## Print this help message
	grep -E '^[\.a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

.env:  ## Ensure there is env file or create one
	echo "No .env file found. Want to create it from .env.example? [y/n]" && read answer && if [ $${answer:-'N'} = 'y' ]; then cp .env.example .env;fi

.PHONY: local-setup
local-setup:  ## Setup local postgres database
	docker compose up -d

.PHONY: up
up: local-setup  ## Run FastAPI development server
	uv run alembic upgrade head
	uv run uvicorn app.main:app --reload --host $(BIND_HOST) --port $(BIND_PORT)

.PHONY: run
run: up  ## Alias for `up`

.PHONY: down
down:  ## Stop database
	docker compose down

.PHONY: test
test: local-setup  ## Run unit tests
	uv run pytest .

.PHONY: lint
lint: local-setup  ## Run all linters
	uv run pre-commit run -a
	uv run mypy .


================================================
FILE: README.md
================================================
# Minimal FastAPI PostgreSQL template

[![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/)
[![License](https://img.shields.io/github/license/rafsaf/minimal-fastapi-postgres-template)](https://github.com/rafsaf/minimal-fastapi-postgres-template/blob/main/LICENSE)
[![Python 3.14](https://img.shields.io/badge/python-3.14-blue)](https://docs.python.org/3/whatsnew/3.14.html)
[![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)
[![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)

_Check out online example: [https://minimal-fastapi-postgres-template.rafsaf.pl](https://minimal-fastapi-postgres-template.rafsaf.pl), it's 100% code used in template (docker image) with added my domain and https only._

- [Minimal FastAPI PostgreSQL template](#minimal-fastapi-postgresql-template)
  - [About](#about)
  - [Features](#features)
  - [Quickstart](#quickstart)
    - [1. Create repository from a template](#1-create-repository-from-a-template)
    - [2. Install dependencies with uv](#2-install-dependencies-with-uv)
    - [3. Run app](#3-run-app)
    - [4. Activate pre-commit](#4-activate-pre-commit)
    - [5. Running tests](#5-running-tests)
  - [Step by step example - POST and GET endpoints](#step-by-step-example---post-and-get-endpoints)
    - [1. Create new app](#1-create-new-app)
    - [2. Create SQLAlchemy model](#2-create-sqlalchemy-model)
    - [3. Import new models.py file in alembic env.py](#3-import-new-modelspy-file-in-alembic-envpy)
    - [4. Create and apply alembic migration](#4-create-and-apply-alembic-migration)
    - [5. Create request and response schemas](#5-create-request-and-response-schemas)
    - [6. Create endpoints](#6-create-endpoints)
    - [7. Add Pet model to tests factories](#7-add-pet-model-to-tests-factories)
    - [8. Create new test file](#8-create-new-test-file)
    - [9. Write tests](#9-write-tests)
  - [Design choices](#design-choices)
    - [Dockerfile](#dockerfile)
    - [Registration](#registration)
    - [Delete user endpoint](#delete-user-endpoint)
    - [JWT and refresh tokens](#jwt-and-refresh-tokens)
    - [Writing scripts / cron](#writing-scripts--cron)
    - [Docs URL](#docs-url)
    - [CORS](#cors)
    - [Allowed Hosts](#allowed-hosts)
  - [License](#license)

## About

If you are curious about latest changes and rationale, read 2026 update blog post: [Update of minimal-fastapi-postgres-template to version 7.0.0](https://rafsaf.pl/blog/2026/02/07/update-of-minimal-fastapi-postgres-template-to-version-7.0.0/).

Enjoy!

## Features

- [x] Template repository.
- [x] [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) 2.0, async queries, best possible autocompletion support.
- [x] PostgreSQL 18 database under [asyncpg](https://github.com/MagicStack/asyncpg) interface.
- [x] Full [Alembic](https://github.com/alembic/alembic) migrations setup (also in unit tests).
- [x] Secure and tested setup for [PyJWT](https://github.com/jpadilla/pyjwt) and [bcrypt](https://github.com/pyca/bcrypt).
- [x] Ready to go Dockerfile with [uvicorn](https://www.uvicorn.org/) webserver.
- [x] [uv](https://docs.astral.sh/uv/getting-started/installation/), [mypy](https://github.com/python/mypy), [pre-commit](https://github.com/pre-commit/pre-commit) hooks with [ruff](https://github.com/astral-sh/ruff).
- [x] Perfect pytest asynchronous test setup and full coverage.

![template-fastapi-minimal-openapi-example](https://rafsaf.pl/blog/2026/02/07/update-of-minimal-fastapi-postgres-template-to-version-7.0.0/minimal-fastapi-postgres-template-2026-02-07-version-7.0.0.png)

## Quickstart

### 1. Create repository from a template

See [docs](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template) or just use git clone.

### 2. Install dependencies with [uv](https://docs.astral.sh/uv/getting-started/installation/)

```bash
cd your_project_name

uv sync

```

Uv should automatically install Python version currently required by template (>=3.14) or use existing Python installation if you already have it.

### 3. Run app

```bash
make up

```

Refer to `Makefile` to see shortcut (`apt install build-essential` - on linux)

If you want to work without it, this should do:

```bash
docker compose up -d

alembic upgrade head

uvicorn app.main:app --reload

```

You should then use `git init` (if needed) to initialize git repository and access OpenAPI spec at [http://localhost:8000/](http://localhost:8000/) by default. See last section for customizations.

### 4. Activate pre-commit

[pre-commit](https://pre-commit.com/) is de facto standard now for pre push activities like isort or black or its nowadays replacement ruff.

Refer to `.pre-commit-config.yaml` file to see my current opinionated choices.

```bash
# Shortcut
make lint

```

Full commands

```bash
# Install pre-commit
pre-commit install --install-hooks

# Run on all files
pre-commit run --all-files

```

### 5. Running tests

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.

For more details about initial database setup, see logic `app/conftest.py` file, especially `fixture_setup_new_test_database` function. Pytest configuration is also in `[tool.pytest.ini_options]` in `pyproject.toml`.

Moreover, there is coverage pytest plugin with required code coverage level 100%.

```bash
# see all pytest configuration flags in pyproject.toml
pytest

# or 
make test

```

## Step by step example - POST and GET endpoints

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:

- `POST` endpoint `/pets/create` for creating `Pets` with relation to currently logged `User`
- `GET` endpoint `/pets/me` for fetching all user's pets.

### 1. Create new app

Add `app/pets` folder and `app/pets/__init__.py`.

### 2. Create SQLAlchemy model

We will add `Pet` model to `app/pets/models.py`.

```python
# app/pets/models.py

import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column

from app.core.models import Base


class Pet(Base):
    __tablename__ = "pets_pet"

    id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True)
    user_id: Mapped[str] = mapped_column(
        sa.ForeignKey("auth_user.user_id", ondelete="CASCADE"),
    )
    pet_name: Mapped[str] = mapped_column(sa.String(50), nullable=False)

```

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](https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html) part of documentation.

### 3. Import new models.py file in alembic env.py

Without this step, alembic won't be able to follow changes in new `models.py` file. In `alembic/env.py` import new file

```python
# alembic/env.py

(...) 
# import other models here
import app.pets.models  # noqa

(...)

```

### 4. Create and apply alembic migration

```bash
### Use below commands in root folder in virtualenv ###

# if you see FAILED: Target database is not up to date.
# first use alembic upgrade head

# Create migration with alembic revision
alembic revision --autogenerate -m "create_pet_model"


# File similar to "2022050949_create_pet_model_44b7b689ea5f.py" should appear in `/alembic/versions` folder


# Apply migration using alembic upgrade
alembic upgrade head

# (...)
# INFO  [alembic.runtime.migration] Running upgrade d1252175c146 -> 44b7b689ea5f, create_pet_model
```

PS. Note, alembic is configured in a way that it work with async setup and also detects specific column changes if using `--autogenerate` flag.

### 5. Create request and response schemas

```python
# app/pets/schemas.py

from pydantic import BaseModel, ConfigDict


class PetCreateRequest(BaseModel):
    pet_name: str


class PetResponse(BaseModel):
    id: int
    pet_name: str
    user_id: str

    model_config = ConfigDict(from_attributes=True)

```

### 6. Create endpoints

```python
# app/pets/views.py

from fastapi import APIRouter, Depends, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.dependencies import get_current_user
from app.auth.models import User
from app.core import database_session
from app.pets.models import Pet
from app.pets.schemas import PetCreateRequest, PetResponse

router = APIRouter()


@router.post(
    "/create",
    response_model=PetResponse,
    status_code=status.HTTP_201_CREATED,
    description="Creates new pet. Only for logged users.",
)
async def create_new_pet(
    data: PetCreateRequest,
    session: AsyncSession = Depends(database_session.new_async_session),
    current_user: User = Depends(get_current_user),
) -> Pet:
    new_pet = Pet(user_id=current_user.user_id, pet_name=data.pet_name)

    session.add(new_pet)
    await session.commit()

    return new_pet


@router.get(
    "/me",
    response_model=list[PetResponse],
    status_code=status.HTTP_200_OK,
    description="Get list of pets for currently logged user.",
)
async def get_all_my_pets(
    session: AsyncSession = Depends(database_session.new_async_session),
    current_user: User = Depends(get_current_user),
) -> list[Pet]:
    pets = await session.scalars(
        select(Pet).where(Pet.user_id == current_user.user_id).order_by(Pet.pet_name)
    )

    return list(pets.all())

```

Now we need to add newly created router to `main.py` app.

```python
# main.py

(...)

from app.pets.views import router as pets_router

(...)

app.include_router(pets_router, prefix="/pets", tags=["pets"])

```

### 7. Add Pet model to tests factories

File `app/tests/factories.py` contains `User` model factory already. Every new DB model should also have it, as it really simplify things later (when you have more models and relationships).

```python
# app/tests/factories.py
(...)

from app.pets.models import Pet

(...)

class PetFactory(SQLAlchemyFactory[Pet]):
    pet_name = Use(Faker().first_name)

```

### 8. Create new test file

Create folder `app/pet/tests` and inside files `__init__.py` and eg. `test_pets_views.py`.

### 9. Write tests

We will write two really simple tests into new file `test_pets_views.py`

```python
# app/pet/tests/test_pets_views.py

from fastapi import status
from httpx import AsyncClient

from app.auth.models import User
from app.main import app
from app.tests.factories import PetFactory


async def test_create_new_pet(
    client: AsyncClient, default_user_headers: dict[str, str], default_user: User
) -> None:
    response = await client.post(
        app.url_path_for("create_new_pet"),
        headers=default_user_headers,
        json={"pet_name": "Tadeusz"},
    )
    assert response.status_code == status.HTTP_201_CREATED

    result = response.json()
    assert result["user_id"] == default_user.user_id
    assert result["pet_name"] == "Tadeusz"


async def test_get_all_my_pets(
    client: AsyncClient,
    default_user_headers: dict[str, str],
    default_user: User,
) -> None:
    pet1 = await PetFactory.create_async(
        user_id=default_user.user_id, pet_name="Alfred"
    )
    pet2 = await PetFactory.create_async(
        user_id=default_user.user_id, pet_name="Tadeusz"
    )

    response = await client.get(
        app.url_path_for("get_all_my_pets"),
        headers=default_user_headers,
    )
    assert response.status_code == status.HTTP_200_OK

    assert response.json() == [
        {
            "user_id": pet1.user_id,
            "pet_name": pet1.pet_name,
            "id": pet1.id,
        },
        {
            "user_id": pet2.user_id,
            "pet_name": pet2.pet_name,
            "id": pet2.id,
        },
    ]

```

## Design choices

There are couple decisions to make and changes that can/should be done after fork. I try to describe below things I consider most opinionated.

### Dockerfile

This template has by default included `Dockerfile` with [Uvicorn](https://www.uvicorn.org/) webserver, because it's simple in 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. Ye, **it's safe**(as much as anything is safe), you don't need anything except prefered LB. Other webservers to consider: [Nginx Unit](https://unit.nginx.org/), [Daphne](https://github.com/django/daphne), [Hypercorn](https://pgjones.gitlab.io/hypercorn/index.html).

### Registration

Is open. You would probably want to either remove it altogether or change.

### Delete user endpoint

Rethink `delete_current_user`, maybe you don't need it.

### JWT and refresh tokens

By using `/auth/access-token` user can exchange username + password for JWT. Refresh tokens is saved **in database table**. I've seen a lot of other, not always secure or sane setups. It's up to you if you want to change it to be also JWT (which seems to be popular), just one small note: It's `good` design if one can revoke all or preferably some refresh tokens. It's much `worse` design if one cannot. On the other hand, it's fine not to have option to revoke access tokens (as they are shortlived).

### Writing scripts / cron

Very rarely app has not some kind of background tasks. Feel free to use `new_script_async_session` if you need to have access to database outside of FastAPI. Cron can be simply: new file, async task with session (doing something), wrapped by `asyncio.run(script_func())`.

### Docs URL

Docs page is simply `/` (by default in FastAPI it is `/docs`). You can change it completely for the project, just as title, version, etc.

```python
app = FastAPI(
    title="minimal fastapi postgres template",
    version="7.0.0",
    description="https://github.com/rafsaf/minimal-fastapi-postgres-template",
    openapi_url="/openapi.json",
    docs_url="/",
)
```

### CORS

If you are not sure what are CORS for, follow [developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS). 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 there, like `https://my-frontend-app.example.com`.

```python
app.add_middleware(
    CORSMiddleware,
    allow_origins=[str(origin) for origin in config.settings.BACKEND_CORS_ORIGINS],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
```

### Allowed Hosts

This middleware prevents HTTP Host Headers attack, you should put here your server IP or (preferably) full domain under which it's accessible like `example.com`. By default `"localhost", "127.0.0.1", "0.0.0.0"`

```python
app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS)
```

## License

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.


================================================
FILE: alembic/README
================================================
Generic single-database configuration.

================================================
FILE: alembic/env.py
================================================
import asyncio
from logging.config import fileConfig

from sqlalchemy import Connection, engine_from_config, pool
from sqlalchemy.ext.asyncio import AsyncEngine

from alembic import context
from app.core.config import get_settings

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)  # type: ignore

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from app.core.models import Base  # noqa

target_metadata = Base.metadata

# import other models here
import app.auth.models  # noqa


def get_database_uri() -> str:
    return get_settings().sqlalchemy_database_uri.render_as_string(hide_password=False)


def run_migrations_offline() -> None:
    """Run migrations in 'offline' mode.

    This configures the context with just a URL
    and not an Engine, though an Engine is acceptable
    here as well.  By skipping the Engine creation
    we don't even need a DBAPI to be available.

    Calls to context.execute() here emit the given string to the
    script output.

    """
    url = get_database_uri()
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
        compare_type=True,
        compare_server_default=True,
    )

    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection: Connection | None) -> None:
    context.configure(
        connection=connection, target_metadata=target_metadata, compare_type=True
    )

    with context.begin_transaction():
        context.run_migrations()


async def run_migrations_online() -> None:
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """
    configuration = config.get_section(config.config_ini_section)
    assert configuration
    configuration["sqlalchemy.url"] = get_database_uri()
    connectable = AsyncEngine(
        engine_from_config(
            configuration,
            prefix="sqlalchemy.",
            poolclass=pool.NullPool,
            future=True,
        )
    )
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)


if context.is_offline_mode():
    run_migrations_offline()
else:
    try:
        loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
    except RuntimeError:
        loop = None

    if loop and loop.is_running():
        # pytest-asyncio or other test runner is running the event loop
        # so we need to use run_coroutine_threadsafe
        future = asyncio.run_coroutine_threadsafe(run_migrations_online(), loop)
        future.result(timeout=15)
    else:
        # no event loop is running, safe to use asyncio.run
        asyncio.run(run_migrations_online())


================================================
FILE: alembic/script.py.mako
================================================
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade():
    ${upgrades if upgrades else "pass"}


def downgrade():
    ${downgrades if downgrades else "pass"}


================================================
FILE: alembic/versions/20260203_1616_initial_auth_683275eeb305.py
================================================
"""initial_auth

Revision ID: 683275eeb305
Revises:
Create Date: 2026-02-03 16:16:09.776977

"""

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "683275eeb305"
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table(
        "auth_user",
        sa.Column("user_id", sa.String(length=36), nullable=False),
        sa.Column("email", sa.String(length=256), nullable=False),
        sa.Column("hashed_password", sa.String(length=128), nullable=False),
        sa.Column(
            "created_at",
            sa.DateTime(timezone=True),
            server_default=sa.text("now()"),
            nullable=False,
        ),
        sa.Column(
            "updated_at",
            sa.DateTime(timezone=True),
            server_default=sa.text("now()"),
            nullable=False,
        ),
        sa.PrimaryKeyConstraint("user_id"),
    )
    op.create_index(op.f("ix_auth_user_email"), "auth_user", ["email"], unique=True)
    op.create_table(
        "auth_refresh_token",
        sa.Column("id", sa.BigInteger(), nullable=False),
        sa.Column("refresh_token", sa.String(length=512), nullable=False),
        sa.Column("used", sa.Boolean(), nullable=False),
        sa.Column("exp", sa.BigInteger(), nullable=False),
        sa.Column("user_id", sa.String(length=36), nullable=False),
        sa.ForeignKeyConstraint(["user_id"], ["auth_user.user_id"], ondelete="CASCADE"),
        sa.PrimaryKeyConstraint("id"),
    )
    op.create_index(
        op.f("ix_auth_refresh_token_refresh_token"),
        "auth_refresh_token",
        ["refresh_token"],
        unique=True,
    )
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(
        op.f("ix_auth_refresh_token_refresh_token"), table_name="auth_refresh_token"
    )
    op.drop_table("auth_refresh_token")
    op.drop_index(op.f("ix_auth_user_email"), table_name="auth_user")
    op.drop_table("auth_user")
    # ### end Alembic commands ###


================================================
FILE: alembic.ini
================================================
# A generic, single database configuration.

[alembic]
# path to migration scripts
script_location = alembic

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s_%%(rev)s

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .

# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =

# max length of characters to apply to the
# "slug" field
truncate_slug_length = 40

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false

# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false

# version location specification; This defaults
# to ${script_location}/versions.  When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions

# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os  # Use os.pathsep. Default configuration used for new projects.
path_separator=os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8

sqlalchemy.url = driver://user:pass@localhost/dbname

[post_write_hooks]
hooks = pre_commit
pre_commit.type = console_scripts
pre_commit.entrypoint = pre-commit
pre_commit.options = run --files REVISION_SCRIPT_FILENAME
# This section defines scripts or Python functions that are run
# on newly generated revision scripts.  See the documentation for further
# detail and examples

# format using "black" - use the console_scripts runner,
# against the "black" entrypoint


# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

================================================
FILE: app/__init__.py
================================================


================================================
FILE: app/auth/api_messages.py
================================================
from typing import Any

JWT_ERROR_USER_REMOVED = "User removed"
PASSWORD_INVALID = "Incorrect email or password"
REFRESH_TOKEN_NOT_FOUND = "Refresh token not found"
REFRESH_TOKEN_EXPIRED = "Refresh token expired"
REFRESH_TOKEN_ALREADY_USED = "Refresh token already used"
EMAIL_ADDRESS_ALREADY_USED = "Cannot use this email address"


UNAUTHORIZED_RESPONSES: dict[int | str, dict[str, Any]] = {
    401: {
        "description": "No `Authorization` access token header, token is invalid or user removed",
        "content": {
            "application/json": {
                "examples": {
                    "not authenticated": {
                        "summary": "No authorization token header",
                        "value": {"detail": "Not authenticated"},
                    },
                    "invalid token": {
                        "summary": "Token validation failed, decode failed, it may be expired or malformed",
                        "value": {"detail": "Token invalid: {detailed error msg}"},
                    },
                    "removed user": {
                        "summary": JWT_ERROR_USER_REMOVED,
                        "value": {"detail": JWT_ERROR_USER_REMOVED},
                    },
                }
            }
        },
    }
}

ACCESS_TOKEN_RESPONSES: dict[int | str, dict[str, Any]] = {
    400: {
        "description": "Invalid email or password",
        "content": {"application/json": {"example": {"detail": PASSWORD_INVALID}}},
    },
}

REFRESH_TOKEN_RESPONSES: dict[int | str, dict[str, Any]] = {
    400: {
        "description": "Refresh token expired or is already used",
        "content": {
            "application/json": {
                "examples": {
                    "refresh token expired": {
                        "summary": REFRESH_TOKEN_EXPIRED,
                        "value": {"detail": REFRESH_TOKEN_EXPIRED},
                    },
                    "refresh token already used": {
                        "summary": REFRESH_TOKEN_ALREADY_USED,
                        "value": {"detail": REFRESH_TOKEN_ALREADY_USED},
                    },
                }
            }
        },
    },
    404: {
        "description": "Refresh token does not exist",
        "content": {
            "application/json": {"example": {"detail": REFRESH_TOKEN_NOT_FOUND}}
        },
    },
}


================================================
FILE: app/auth/dependencies.py
================================================
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import api_messages
from app.auth.jwt import verify_jwt_token
from app.auth.models import User
from app.core import database_session

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/access-token")


async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    session: AsyncSession = Depends(database_session.new_async_session),
) -> User:
    token_payload = verify_jwt_token(token)

    user = await session.scalar(select(User).where(User.user_id == token_payload.sub))

    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=api_messages.JWT_ERROR_USER_REMOVED,
        )
    return user


================================================
FILE: app/auth/jwt.py
================================================
import time

import jwt
from fastapi import HTTPException, status
from pydantic import BaseModel

from app.core.config import get_settings


# Payload follows RFC 7519
# https://www.rfc-editor.org/rfc/rfc7519#section-4.1
class JWTTokenPayload(BaseModel):
    iss: str
    sub: str
    exp: int
    iat: int


class JWTToken(BaseModel):
    payload: JWTTokenPayload
    access_token: str


def create_jwt_token(user_id: str) -> JWTToken:
    iat = int(time.time())
    exp = iat + get_settings().security.jwt_access_token_expire_secs

    token_payload = JWTTokenPayload(
        iss=get_settings().security.jwt_issuer,
        sub=user_id,
        exp=exp,
        iat=iat,
    )

    access_token = jwt.encode(
        token_payload.model_dump(),
        key=get_settings().security.jwt_secret_key.get_secret_value(),
        algorithm=get_settings().security.jwt_algorithm,
    )

    return JWTToken(payload=token_payload, access_token=access_token)


def verify_jwt_token(token: str) -> JWTTokenPayload:
    # Pay attention to verify_signature passed explicite, even if it is the default.
    # Verification is based on expected payload fields like "exp", "iat" etc.
    # so if you rename for example "exp" to "my_custom_exp", this is gonna break,
    # jwt.ExpiredSignatureError will not be raised, that can potentialy
    # be major security risk - not validating tokens at all.
    # If unsure, jump into jwt.decode code, make sure tests are passing
    # https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-hs256

    try:
        raw_payload = jwt.decode(
            token,
            get_settings().security.jwt_secret_key.get_secret_value(),
            algorithms=[get_settings().security.jwt_algorithm],
            options={"verify_signature": True},
            issuer=get_settings().security.jwt_issuer,
        )
    except jwt.InvalidTokenError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Token invalid: {e}",
        )

    return JWTTokenPayload(**raw_payload)


================================================
FILE: app/auth/models.py
================================================
# SQL Alchemy models declaration.
# https://docs.sqlalchemy.org/en/20/orm/quickstart.html#declare-models
# mapped_column syntax from SQLAlchemy 2.0.

# https://alembic.sqlalchemy.org/en/latest/tutorial.html
# Note, it is used by alembic migrations logic, see `alembic/env.py`

# Alembic shortcuts:
# # create migration
# alembic revision --autogenerate -m "migration_name"

# # apply all migrations
# alembic upgrade head


import uuid
from datetime import datetime

import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.core.models import Base


class User(Base):
    __tablename__ = "auth_user"

    user_id: Mapped[str] = mapped_column(
        sa.String(36), primary_key=True, default=lambda _: str(uuid.uuid4())
    )
    email: Mapped[str] = mapped_column(
        sa.String(256), nullable=False, unique=True, index=True
    )
    hashed_password: Mapped[str] = mapped_column(sa.String(128), nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
    )
    updated_at: Mapped[datetime] = mapped_column(
        sa.DateTime(timezone=True),
        server_default=sa.func.now(),
        onupdate=sa.func.now(),
        nullable=False,
    )

    refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user")  # noqa: UP037


class RefreshToken(Base):
    __tablename__ = "auth_refresh_token"

    id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True)
    refresh_token: Mapped[str] = mapped_column(
        sa.String(512), nullable=False, unique=True, index=True
    )
    used: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False)
    exp: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)

    user_id: Mapped[str] = mapped_column(
        sa.ForeignKey("auth_user.user_id", ondelete="CASCADE"),
    )
    user: Mapped[User] = relationship(back_populates="refresh_tokens")


================================================
FILE: app/auth/password.py
================================================
import bcrypt

from app.core.config import get_settings


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return bcrypt.checkpw(
        plain_password.encode("utf-8"), hashed_password.encode("utf-8")
    )


def get_password_hash(password: str) -> str:
    return bcrypt.hashpw(
        password.encode(),
        bcrypt.gensalt(get_settings().security.password_bcrypt_rounds),
    ).decode()


DUMMY_PASSWORD = get_password_hash("")


================================================
FILE: app/auth/responses.py
================================================
from pydantic import BaseModel, ConfigDict, EmailStr


class BaseResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)


class AccessTokenResponse(BaseResponse):
    token_type: str = "Bearer"
    access_token: str
    expires_at: int
    refresh_token: str
    refresh_token_expires_at: int


class UserResponse(BaseResponse):
    user_id: str
    email: EmailStr


================================================
FILE: app/auth/schemas.py
================================================
from pydantic import BaseModel, ConfigDict, EmailStr


class RefreshTokenRequest(BaseModel):
    refresh_token: str


class UserUpdatePasswordRequest(BaseModel):
    password: str


class UserCreateRequest(BaseModel):
    email: EmailStr
    password: str


class AccessTokenResponse(BaseModel):
    token_type: str = "Bearer"
    access_token: str
    expires_at: int
    refresh_token: str
    refresh_token_expires_at: int

    model_config = ConfigDict(from_attributes=True)


class UserResponse(BaseModel):
    user_id: str
    email: EmailStr

    model_config = ConfigDict(from_attributes=True)


================================================
FILE: app/auth/tests/__init__.py
================================================


================================================
FILE: app/auth/tests/test_jwt.py
================================================
import time

import pytest
from fastapi import HTTPException
from freezegun import freeze_time
from pydantic import SecretStr

from app.auth import jwt
from app.core.config import get_settings


def test_jwt_access_token_can_be_decoded_back_into_user_id() -> None:
    user_id = "test_user_id"
    token = jwt.create_jwt_token(user_id)

    payload = jwt.verify_jwt_token(token=token.access_token)
    assert payload.sub == user_id


@freeze_time("2024-01-01")
def test_jwt_payload_is_correct() -> None:
    user_id = "test_user_id"
    token = jwt.create_jwt_token(user_id)

    assert token.payload.iat == int(time.time())
    assert token.payload.sub == user_id
    assert token.payload.iss == get_settings().security.jwt_issuer
    assert (
        token.payload.exp
        == int(time.time()) + get_settings().security.jwt_access_token_expire_secs
    )


def test_jwt_error_after_exp_time() -> None:
    user_id = "test_user_id"
    with freeze_time("2024-01-01"):
        token = jwt.create_jwt_token(user_id)
    with freeze_time("2024-02-01"):
        with pytest.raises(HTTPException) as e:
            jwt.verify_jwt_token(token=token.access_token)

        assert e.value.detail == "Token invalid: Signature has expired"


def test_jwt_error_before_iat_time() -> None:
    user_id = "test_user_id"
    with freeze_time("2024-01-01"):
        token = jwt.create_jwt_token(user_id)
    with freeze_time("2023-12-01"):
        with pytest.raises(HTTPException) as e:
            jwt.verify_jwt_token(token=token.access_token)

        assert e.value.detail == "Token invalid: The token is not yet valid (iat)"


def test_jwt_error_with_invalid_token() -> None:
    with pytest.raises(HTTPException) as e:
        jwt.verify_jwt_token(token="invalid!")

    assert e.value.detail == "Token invalid: Not enough segments"


def test_jwt_error_with_invalid_issuer() -> None:
    user_id = "test_user_id"
    token = jwt.create_jwt_token(user_id)

    get_settings().security.jwt_issuer = "another_issuer"

    with pytest.raises(HTTPException) as e:
        jwt.verify_jwt_token(token=token.access_token)

    assert e.value.detail == "Token invalid: Invalid issuer"


def test_jwt_error_with_invalid_secret_key() -> None:
    user_id = "test_user_id"
    token = jwt.create_jwt_token(user_id)

    get_settings().security.jwt_secret_key = SecretStr("x" * 32)

    with pytest.raises(HTTPException) as e:
        jwt.verify_jwt_token(token=token.access_token)

    assert e.value.detail == "Token invalid: Signature verification failed"


================================================
FILE: app/auth/tests/test_password.py
================================================
from app.auth.password import get_password_hash, verify_password


def test_hashed_password_is_verified() -> None:
    pwd_hash = get_password_hash("my_password")
    assert verify_password("my_password", pwd_hash)


def test_invalid_password_is_not_verified() -> None:
    pwd_hash = get_password_hash("my_password")
    assert not verify_password("my_password_invalid", pwd_hash)


================================================
FILE: app/auth/tests/test_view_delete_current_user.py
================================================
from fastapi import status
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.models import User
from app.main import app


async def test_delete_current_user_status_code(
    client: AsyncClient,
    default_user_headers: dict[str, str],
) -> None:
    response = await client.delete(
        app.url_path_for("delete_current_user"),
        headers=default_user_headers,
    )

    assert response.status_code == status.HTTP_204_NO_CONTENT


async def test_delete_current_user_is_deleted_in_db(
    client: AsyncClient,
    default_user_headers: dict[str, str],
    default_user: User,
    session: AsyncSession,
) -> None:
    await client.delete(
        app.url_path_for("delete_current_user"),
        headers=default_user_headers,
    )

    user = await session.scalar(
        select(User).where(User.user_id == default_user.user_id)
    )
    assert user is None


================================================
FILE: app/auth/tests/test_view_login_access_token.py
================================================
import time

from fastapi import status
from freezegun import freeze_time
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import api_messages
from app.auth.jwt import verify_jwt_token
from app.auth.models import RefreshToken, User
from app.core.config import get_settings
from app.main import app
from app.tests.auth import TESTS_USER_PASSWORD


async def test_login_access_token_has_response_status_code(
    client: AsyncClient,
    default_user: User,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": TESTS_USER_PASSWORD,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    assert response.status_code == status.HTTP_200_OK, response.text


async def test_login_access_token_jwt_has_valid_token_type(
    client: AsyncClient,
    default_user: User,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": TESTS_USER_PASSWORD,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    assert response.status_code == status.HTTP_200_OK, response.text
    token = response.json()
    assert token["token_type"] == "Bearer"


@freeze_time("2023-01-01")
async def test_login_access_token_jwt_has_valid_expire_time(
    client: AsyncClient,
    default_user: User,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": TESTS_USER_PASSWORD,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    assert response.status_code == status.HTTP_200_OK, response.text
    token = response.json()
    current_timestamp = int(time.time())
    assert (
        token["expires_at"]
        == current_timestamp + get_settings().security.jwt_access_token_expire_secs
    )


@freeze_time("2023-01-01")
async def test_login_access_token_returns_valid_jwt_access_token(
    client: AsyncClient,
    default_user: User,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": TESTS_USER_PASSWORD,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    assert response.status_code == status.HTTP_200_OK, response.text

    now = int(time.time())
    token = response.json()
    token_payload = verify_jwt_token(token["access_token"])

    assert token_payload.sub == default_user.user_id
    assert token_payload.iat == now
    assert token_payload.exp == token["expires_at"]


async def test_login_access_token_refresh_token_has_valid_expire_time(
    client: AsyncClient,
    default_user: User,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": TESTS_USER_PASSWORD,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    assert response.status_code == status.HTTP_200_OK, response.text

    token = response.json()
    current_time = int(time.time())
    assert (
        token["refresh_token_expires_at"]
        == current_time + get_settings().security.jwt_refresh_token_expire_secs
    )


async def test_login_access_token_refresh_token_exists_in_db(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": TESTS_USER_PASSWORD,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    assert response.status_code == status.HTTP_200_OK, response.text

    token = response.json()

    token_db_count = await session.scalar(
        select(func.count()).where(RefreshToken.refresh_token == token["refresh_token"])
    )
    assert token_db_count == 1


async def test_login_access_token_refresh_token_in_db_has_valid_fields(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": TESTS_USER_PASSWORD,
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    assert response.status_code == status.HTTP_200_OK, response.text

    token = response.json()
    result = await session.scalars(
        select(RefreshToken).where(RefreshToken.refresh_token == token["refresh_token"])
    )
    refresh_token = result.one()

    assert refresh_token.user_id == default_user.user_id
    assert refresh_token.exp == token["refresh_token_expires_at"]
    assert not refresh_token.used


async def test_auth_access_token_fail_for_not_existing_user_with_message(
    client: AsyncClient,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": "non-existing",
            "password": "bla",
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )

    assert response.status_code == status.HTTP_400_BAD_REQUEST, response.text
    assert response.json() == {"detail": api_messages.PASSWORD_INVALID}


async def test_auth_access_token_fail_for_invalid_password_with_message(
    client: AsyncClient,
    default_user: User,
) -> None:
    response = await client.post(
        app.url_path_for("login_access_token"),
        data={
            "username": default_user.email,
            "password": "invalid",
        },
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )

    assert response.status_code == status.HTTP_400_BAD_REQUEST, response.text
    assert response.json() == {"detail": api_messages.PASSWORD_INVALID}


================================================
FILE: app/auth/tests/test_view_read_current_user.py
================================================
from fastapi import status
from freezegun import freeze_time
from httpx import AsyncClient
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import api_messages
from app.auth.jwt import create_jwt_token
from app.auth.models import User
from app.main import app


async def test_read_current_user_status_code(
    client: AsyncClient,
    default_user_headers: dict[str, str],
    default_user: User,
) -> None:
    response = await client.get(
        app.url_path_for("read_current_user"),
        headers=default_user_headers,
    )

    assert response.status_code == status.HTTP_200_OK


async def test_read_current_user_response(
    client: AsyncClient,
    default_user_headers: dict[str, str],
    default_user: User,
) -> None:
    response = await client.get(
        app.url_path_for("read_current_user"),
        headers=default_user_headers,
    )

    assert response.json() == {
        "user_id": default_user.user_id,
        "email": default_user.email,
    }


async def test_api_raise_401_on_jwt_decode_errors(
    client: AsyncClient,
) -> None:
    response = await client.get(
        app.url_path_for("read_current_user"),
        headers={"Authorization": "Bearer garbage-invalid-jwt"},
    )

    assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text
    assert response.json() == {"detail": "Token invalid: Not enough segments"}


async def test_api_raise_401_on_jwt_expired_token(
    client: AsyncClient,
    default_user: User,
) -> None:
    with freeze_time("2023-01-01"):
        jwt = create_jwt_token(default_user.user_id)
    with freeze_time("2023-02-01"):
        response = await client.get(
            app.url_path_for("read_current_user"),
            headers={"Authorization": f"Bearer {jwt.access_token}"},
        )

        assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text
        assert response.json() == {"detail": "Token invalid: Signature has expired"}


async def test_api_raise_401_on_jwt_user_deleted(
    client: AsyncClient,
    default_user_headers: dict[str, str],
    default_user: User,
    session: AsyncSession,
) -> None:
    await session.execute(delete(User).where(User.user_id == default_user.user_id))
    await session.commit()

    response = await client.get(
        app.url_path_for("read_current_user"),
        headers=default_user_headers,
    )
    assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text
    assert response.json() == {"detail": api_messages.JWT_ERROR_USER_REMOVED}


================================================
FILE: app/auth/tests/test_view_refresh_token.py
================================================
import time

from fastapi import status
from freezegun import freeze_time
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import api_messages
from app.auth.jwt import verify_jwt_token
from app.auth.models import RefreshToken, User
from app.core.config import get_settings
from app.main import app


async def test_refresh_token_fails_with_message_when_token_does_not_exist(
    client: AsyncClient,
) -> None:
    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    assert response.status_code == status.HTTP_404_NOT_FOUND
    assert response.json() == {"detail": api_messages.REFRESH_TOKEN_NOT_FOUND}


async def test_refresh_token_fails_with_message_when_token_is_expired(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) - 1,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    assert response.status_code == status.HTTP_400_BAD_REQUEST
    assert response.json() == {"detail": api_messages.REFRESH_TOKEN_EXPIRED}


async def test_refresh_token_fails_with_message_when_token_is_used(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=True,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    assert response.status_code == status.HTTP_400_BAD_REQUEST
    assert response.json() == {"detail": api_messages.REFRESH_TOKEN_ALREADY_USED}


async def test_refresh_token_success_response_status_code(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=False,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    assert response.status_code == status.HTTP_200_OK


async def test_refresh_token_success_old_token_is_used(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=False,
    )
    session.add(test_refresh_token)
    await session.commit()

    await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    used_test_refresh_token = await session.scalar(
        select(RefreshToken).where(RefreshToken.refresh_token == "blaxx")
    )
    assert used_test_refresh_token is not None
    assert used_test_refresh_token.used


async def test_refresh_token_success_jwt_has_valid_token_type(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=False,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    token = response.json()
    assert token["token_type"] == "Bearer"


@freeze_time("2023-01-01")
async def test_refresh_token_success_jwt_has_valid_expire_time(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=False,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    token = response.json()
    current_timestamp = int(time.time())
    assert (
        token["expires_at"]
        == current_timestamp + get_settings().security.jwt_access_token_expire_secs
    )


@freeze_time("2023-01-01")
async def test_refresh_token_success_jwt_has_valid_access_token(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=False,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    now = int(time.time())
    token = response.json()
    token_payload = verify_jwt_token(token["access_token"])

    assert token_payload.sub == default_user.user_id
    assert token_payload.iat == now
    assert token_payload.exp == token["expires_at"]


@freeze_time("2023-01-01")
async def test_refresh_token_success_refresh_token_has_valid_expire_time(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=False,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    token = response.json()
    current_time = int(time.time())
    assert (
        token["refresh_token_expires_at"]
        == current_time + get_settings().security.jwt_refresh_token_expire_secs
    )


async def test_refresh_token_success_new_refresh_token_is_in_db(
    client: AsyncClient,
    default_user: User,
    session: AsyncSession,
) -> None:
    test_refresh_token = RefreshToken(
        user_id=default_user.user_id,
        refresh_token="blaxx",
        exp=int(time.time()) + 1000,
        used=False,
    )
    session.add(test_refresh_token)
    await session.commit()

    response = await client.post(
        app.url_path_for("refresh_token"),
        json={
            "refresh_token": "blaxx",
        },
    )

    token = response.json()
    token_db_count = await session.scalar(
        select(func.count()).where(RefreshToken.refresh_token == token["refresh_token"])
    )
    assert token_db_count == 1


================================================
FILE: app/auth/tests/test_view_register_new_user.py
================================================
from fastapi import status
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import api_messages
from app.auth.models import User
from app.main import app
from app.tests.factories import UserFactory


async def test_register_new_user_status_code(
    client: AsyncClient,
) -> None:
    response = await client.post(
        app.url_path_for("register_new_user"),
        json={
            "email": "test@email.com",
            "password": "testtesttest",
        },
    )

    assert response.status_code == status.HTTP_201_CREATED


async def test_register_new_user_creates_record_in_db(
    client: AsyncClient,
    session: AsyncSession,
) -> None:
    await client.post(
        app.url_path_for("register_new_user"),
        json={
            "email": "test@email.com",
            "password": "testtesttest",
        },
    )

    user_count = await session.scalar(
        select(func.count()).where(User.email == "test@email.com")
    )
    assert user_count == 1


async def test_register_new_user_cannot_create_already_created_user(
    client: AsyncClient,
    session: AsyncSession,
) -> None:
    existing_user = await UserFactory.create_async()

    response = await client.post(
        app.url_path_for("register_new_user"),
        json={
            "email": existing_user.email,
            "password": "testtesttest",
        },
    )

    assert response.status_code == status.HTTP_400_BAD_REQUEST
    assert response.json() == {"detail": api_messages.EMAIL_ADDRESS_ALREADY_USED}


================================================
FILE: app/auth/tests/test_view_reset_current_user_password.py
================================================
from fastapi import status
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth.models import User
from app.auth.password import verify_password
from app.main import app


async def test_reset_current_user_password_status_code(
    client: AsyncClient,
    default_user_headers: dict[str, str],
) -> None:
    response = await client.post(
        app.url_path_for("reset_current_user_password"),
        headers=default_user_headers,
        json={"password": "test_pwd"},
    )

    assert response.status_code == status.HTTP_204_NO_CONTENT


async def test_reset_current_user_password_is_changed_in_db(
    client: AsyncClient,
    default_user_headers: dict[str, str],
    default_user: User,
    session: AsyncSession,
) -> None:
    await client.post(
        app.url_path_for("reset_current_user_password"),
        headers=default_user_headers,
        json={"password": "test_pwd"},
    )

    user = await session.scalar(
        select(User).where(User.user_id == default_user.user_id)
    )
    assert user is not None
    assert verify_password("test_pwd", user.hashed_password)


================================================
FILE: app/auth/views.py
================================================
import secrets
import time

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import api_messages, dependencies
from app.auth.jwt import create_jwt_token
from app.auth.models import RefreshToken, User
from app.auth.password import (
    DUMMY_PASSWORD,
    get_password_hash,
    verify_password,
)
from app.auth.schemas import (
    AccessTokenResponse,
    RefreshTokenRequest,
    UserCreateRequest,
    UserResponse,
    UserUpdatePasswordRequest,
)
from app.core.config import get_settings
from app.core.database_session import new_async_session

router = APIRouter(responses=api_messages.UNAUTHORIZED_RESPONSES)


@router.get("/me", response_model=UserResponse, description="Get current user")
async def read_current_user(
    current_user: User = Depends(dependencies.get_current_user),
) -> User:
    return current_user


@router.delete(
    "/me",
    status_code=status.HTTP_204_NO_CONTENT,
    description="Delete current user",
)
async def delete_current_user(
    current_user: User = Depends(dependencies.get_current_user),
    session: AsyncSession = Depends(new_async_session),
) -> None:
    await session.execute(delete(User).where(User.user_id == current_user.user_id))
    await session.commit()


@router.post(
    "/reset-password",
    status_code=status.HTTP_204_NO_CONTENT,
    description="Update current user password",
)
async def reset_current_user_password(
    user_update_password: UserUpdatePasswordRequest,
    session: AsyncSession = Depends(new_async_session),
    current_user: User = Depends(dependencies.get_current_user),
) -> None:
    current_user.hashed_password = get_password_hash(user_update_password.password)
    session.add(current_user)
    await session.commit()


@router.post(
    "/access-token",
    response_model=AccessTokenResponse,
    responses=api_messages.ACCESS_TOKEN_RESPONSES,
    description="OAuth2 compatible token, get an access token for future requests using username and password",
)
async def login_access_token(
    session: AsyncSession = Depends(new_async_session),
    form_data: OAuth2PasswordRequestForm = Depends(),
) -> AccessTokenResponse:
    user = await session.scalar(select(User).where(User.email == form_data.username))

    if user is None:
        # this is naive method to not return early
        verify_password(form_data.password, DUMMY_PASSWORD)

        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=api_messages.PASSWORD_INVALID,
        )

    if not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=api_messages.PASSWORD_INVALID,
        )

    jwt_token = create_jwt_token(user_id=user.user_id)

    refresh_token = RefreshToken(
        user_id=user.user_id,
        refresh_token=secrets.token_urlsafe(32),
        exp=int(time.time() + get_settings().security.jwt_refresh_token_expire_secs),
    )
    session.add(refresh_token)
    await session.commit()

    return AccessTokenResponse(
        access_token=jwt_token.access_token,
        expires_at=jwt_token.payload.exp,
        refresh_token=refresh_token.refresh_token,
        refresh_token_expires_at=refresh_token.exp,
    )


@router.post(
    "/refresh-token",
    response_model=AccessTokenResponse,
    responses=api_messages.REFRESH_TOKEN_RESPONSES,
    description="OAuth2 compatible token, get an access token for future requests using refresh token",
)
async def refresh_token(
    data: RefreshTokenRequest,
    session: AsyncSession = Depends(new_async_session),
) -> AccessTokenResponse:
    token = await session.scalar(
        select(RefreshToken)
        .where(RefreshToken.refresh_token == data.refresh_token)
        .with_for_update(skip_locked=True)
    )

    if token is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=api_messages.REFRESH_TOKEN_NOT_FOUND,
        )
    elif time.time() > token.exp:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=api_messages.REFRESH_TOKEN_EXPIRED,
        )
    elif token.used:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=api_messages.REFRESH_TOKEN_ALREADY_USED,
        )

    token.used = True
    session.add(token)

    jwt_token = create_jwt_token(user_id=token.user_id)

    refresh_token = RefreshToken(
        user_id=token.user_id,
        refresh_token=secrets.token_urlsafe(32),
        exp=int(time.time() + get_settings().security.jwt_refresh_token_expire_secs),
    )
    session.add(refresh_token)
    await session.commit()

    return AccessTokenResponse(
        access_token=jwt_token.access_token,
        expires_at=jwt_token.payload.exp,
        refresh_token=refresh_token.refresh_token,
        refresh_token_expires_at=refresh_token.exp,
    )


@router.post(
    "/register",
    response_model=UserResponse,
    description="Create new user",
    status_code=status.HTTP_201_CREATED,
)
async def register_new_user(
    new_user: UserCreateRequest,
    session: AsyncSession = Depends(new_async_session),
) -> User:
    user = await session.scalar(select(User).where(User.email == new_user.email))
    if user is not None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=api_messages.EMAIL_ADDRESS_ALREADY_USED,
        )

    user = User(
        email=new_user.email,
        hashed_password=get_password_hash(new_user.password),
    )
    session.add(user)

    try:
        await session.commit()
    except IntegrityError:  # pragma: no cover
        await session.rollback()

        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=api_messages.EMAIL_ADDRESS_ALREADY_USED,
        )

    return user


================================================
FILE: app/conftest.py
================================================
import asyncio
import os
from collections.abc import AsyncGenerator

import alembic.command
import alembic.config
import pytest
import pytest_asyncio
import sqlalchemy
from httpx import ASGITransport, AsyncClient
from polyfactory.factories.sqlalchemy_factory import (
    SQLAASyncPersistence,
    SQLAlchemyFactory,
)
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
)

from app.auth.jwt import create_jwt_token
from app.auth.models import User
from app.core import database_session
from app.core.config import PROJECT_DIR, get_settings
from app.core.database_session import new_async_session
from app.main import app as fastapi_app
from app.tests.factories import UserFactory


@pytest_asyncio.fixture(scope="session", loop_scope="session", autouse=True)
async def fixture_setup_new_test_database() -> AsyncGenerator[None]:
    worker_name = os.getenv("PYTEST_XDIST_WORKER", "gw0")
    test_db_name = f"test_db_{worker_name}"

    # create new test db using connection to current database
    conn = await database_session._ASYNC_ENGINE.connect()
    await conn.execution_options(isolation_level="AUTOCOMMIT")
    await conn.execute(sqlalchemy.text(f"DROP DATABASE IF EXISTS {test_db_name}"))
    await conn.execute(sqlalchemy.text(f"CREATE DATABASE {test_db_name}"))
    await conn.close()

    # dispose the original engine before switching to test database
    await database_session._ASYNC_ENGINE.dispose()

    session_mpatch = pytest.MonkeyPatch()
    session_mpatch.setenv("DATABASE__DB", test_db_name)
    session_mpatch.setenv("SECURITY__PASSWORD_BCRYPT_ROUNDS", "4")

    # force settings to use now monkeypatched environments
    get_settings.cache_clear()

    # monkeypatch test database engine
    engine = database_session.new_async_engine(get_settings().sqlalchemy_database_uri)

    session_mpatch.setattr(
        database_session,
        "_ASYNC_ENGINE",
        engine,
    )
    session_mpatch.setattr(
        database_session,
        "_ASYNC_SESSIONMAKER",
        async_sessionmaker(engine, expire_on_commit=False),
    )

    def alembic_upgrade() -> None:
        # synchronous function to run alembic upgrade
        alembic_config = alembic.config.Config(PROJECT_DIR / "alembic.ini")
        alembic.command.upgrade(alembic_config, "head")

    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, alembic_upgrade)

    yield

    # cleanup: dispose the test engine
    await engine.dispose()


@pytest_asyncio.fixture(scope="function", loop_scope="session", autouse=True)
async def fixture_clean_get_settings_between_tests() -> AsyncGenerator[None]:
    yield

    get_settings.cache_clear()


@pytest_asyncio.fixture(name="session", loop_scope="session", scope="function")
async def fixture_session_with_rollback(
    monkeypatch: pytest.MonkeyPatch,
) -> AsyncGenerator[AsyncSession]:
    # we want to monkeypatch new_async_session with one bound to session
    # that we will always rollback on function scope

    connection = await database_session._ASYNC_ENGINE.connect()
    transaction = await connection.begin()

    session = AsyncSession(bind=connection, expire_on_commit=False)

    monkeypatch.setattr(
        database_session,
        "new_script_async_session",
        lambda: session,
    )

    fastapi_app.dependency_overrides[new_async_session] = lambda: session

    # now some work around SQLAlchemyFactory to actually use our session
    # refer to https://polyfactory.litestar.dev/latest/usage/configuration.html
    persistence_handler = SQLAASyncPersistence(session=session)  # type: ignore
    setattr(SQLAlchemyFactory, "__async_persistence__", persistence_handler)

    yield session

    setattr(SQLAlchemyFactory, "__async_persistence__", None)

    fastapi_app.dependency_overrides.pop(new_async_session, None)

    await session.close()
    await transaction.rollback()
    await connection.close()


@pytest_asyncio.fixture(name="client", loop_scope="session", scope="function")
async def fixture_client(session: AsyncSession) -> AsyncGenerator[AsyncClient]:
    transport = ASGITransport(app=fastapi_app)
    async with AsyncClient(transport=transport, base_url="http://test") as aclient:
        aclient.headers.update({"Host": "localhost"})
        yield aclient


@pytest_asyncio.fixture(name="default_user", loop_scope="session", scope="function")
async def fixture_default_user(session: AsyncSession) -> AsyncGenerator[User]:
    yield await UserFactory.create_async()


@pytest_asyncio.fixture(
    name="default_user_headers", loop_scope="session", scope="function"
)
async def fixture_default_user_headers(default_user: User) -> dict[str, str]:
    access_token = create_jwt_token(user_id=default_user.user_id).access_token
    return {"Authorization": f"Bearer {access_token}"}


================================================
FILE: app/core/__init__.py
================================================


================================================
FILE: app/core/config.py
================================================
# File with environment variables and general configuration logic.
# Env variables are combined in nested groups like "Security", "Database" etc.
# So environment variable (case-insensitive) for jwt_secret_key will be "security__jwt_secret_key"
#
# Pydantic priority ordering:
#
# 1. (Most important, will overwrite everything) - environment variables
# 2. `.env` file in root folder of project
# 3. Default values
#
# "sqlalchemy_database_uri" is computed field that will create valid database URL
#
# See https://pydantic-docs.helpmanual.io/usage/settings/
# Note, complex types like lists are read as json-encoded strings.


import logging.config
from functools import lru_cache
from pathlib import Path

from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy.engine.url import URL

PROJECT_DIR = Path(__file__).parent.parent.parent


class Security(BaseModel):
    jwt_issuer: str = "my-app"
    jwt_secret_key: SecretStr = SecretStr(
        "change-me-to-a-strong-secret-key-at-least-32-chars-long"
    )
    jwt_access_token_expire_secs: int = Field(default=15 * 60, gt=10)  # 15min
    jwt_refresh_token_expire_secs: int = Field(default=28 * 24 * 3600, gt=60)  # 28d
    jwt_algorithm: str = "HS256"

    password_bcrypt_rounds: int = 12
    allowed_hosts: list[str] = ["localhost", "127.0.0.1", "0.0.0.0"]
    backend_cors_origins: list[AnyHttpUrl] = []


class Database(BaseModel):
    hostname: str = "postgres"
    username: str = "postgres"
    password: SecretStr = SecretStr("passwd-change-me")
    port: int = 5432
    db: str = "postgres"


class Prometheus(BaseModel):
    enabled: bool = False
    port: int = 9090
    addr: str = "0.0.0.0"
    stop_delay_secs: int = 0


class Settings(BaseSettings):
    security: Security = Field(default_factory=Security)
    database: Database = Field(default_factory=Database)
    prometheus: Prometheus = Field(default_factory=Prometheus)

    log_level: str = "INFO"

    @computed_field  # type: ignore[prop-decorator]
    @property
    def sqlalchemy_database_uri(self) -> URL:
        return URL.create(
            drivername="postgresql+asyncpg",
            username=self.database.username,
            password=self.database.password.get_secret_value(),
            host=self.database.hostname,
            port=self.database.port,
            database=self.database.db,
        )

    model_config = SettingsConfigDict(
        env_file=f"{PROJECT_DIR}/.env",
        case_sensitive=False,
        env_nested_delimiter="__",
    )


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    return Settings()


def logging_config(log_level: str) -> None:
    conf = {
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "verbose": {
                "format": "{asctime} [{levelname}] {name}: {message}",
                "style": "{",
            },
        },
        "handlers": {
            "stream": {
                "class": "logging.StreamHandler",
                "formatter": "verbose",
                "level": "DEBUG",
            },
        },
        "loggers": {
            "": {
                "level": log_level,
                "handlers": ["stream"],
                "propagate": True,
            },
        },
    }
    logging.config.dictConfig(conf)


logging_config(log_level=get_settings().log_level)


================================================
FILE: app/core/database_session.py
================================================
# SQLAlchemy async engine and sessions tools
#
# https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
#
# for pool size configuration:
# https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.Pool


from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from sqlalchemy.engine.url import URL
from sqlalchemy.ext.asyncio import (
    AsyncEngine,
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

from app.core.config import get_settings


def new_async_engine(uri: URL) -> AsyncEngine:
    return create_async_engine(
        uri,
        pool_pre_ping=True,
        pool_size=5,
        max_overflow=10,
        pool_timeout=30.0,
        pool_recycle=600,
    )


_ASYNC_ENGINE = new_async_engine(get_settings().sqlalchemy_database_uri)
_ASYNC_SESSIONMAKER = async_sessionmaker(_ASYNC_ENGINE, expire_on_commit=False)


async def new_async_session() -> AsyncGenerator[AsyncSession]:  # pragma: no cover
    session = _ASYNC_SESSIONMAKER()
    try:
        yield session
    finally:
        await session.close()


@asynccontextmanager
async def new_script_async_session() -> AsyncGenerator[
    AsyncSession
]:  # pragma: no cover
    # you can use this version inside scripts that run eg. as cronjobs outside of FastAPI context
    # that you will run with asyncio.run()
    # Global enginer and sessionmaker are created by global loop and cannot be shared across loops,
    # so we need to create new ones here
    _engine = create_async_engine(
        get_settings().sqlalchemy_database_uri, pool_pre_ping=True
    )
    _async_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)

    session = _async_sessionmaker()
    try:
        yield session
    finally:
        await session.close()
        await _engine.dispose()


================================================
FILE: app/core/lifespan.py
================================================
import asyncio
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

import prometheus_client
from fastapi import FastAPI

from app.core import database_session, metrics
from app.core.config import get_settings

logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None]:  # pragma: no cover
    logger.info("starting application...")
    if get_settings().prometheus.enabled:
        logger.info(
            "starting prometheus client server on interface %s port %d",
            get_settings().prometheus.addr,
            get_settings().prometheus.port,
        )

        prometheus_client.start_http_server(
            addr=get_settings().prometheus.addr,
            port=get_settings().prometheus.port,
        )
        metrics.APP_STARTED.inc()

    yield

    logger.info("shutting down application...")

    await database_session._ASYNC_ENGINE.dispose()
    logger.info("disposed database engine and closed connections...")

    if get_settings().prometheus.enabled:
        logger.info(
            "stopping prometheus with delay of %d seconds...",
            get_settings().prometheus.stop_delay_secs,
        )
        metrics.APP_STOPPED.inc()
        await asyncio.sleep(get_settings().prometheus.stop_delay_secs)

    logger.info("bye! application shutdown completed")


================================================
FILE: app/core/metrics.py
================================================
import prometheus_client

NAMESPACE = "org"
SUBSYSTEM = "app"

APP_STARTED = prometheus_client.Counter(
    "app_started_total",
    "FastAPI application start count",
    labelnames=(),
    namespace=NAMESPACE,
    subsystem=SUBSYSTEM,
)

APP_STOPPED = prometheus_client.Counter(
    "app_stopped",
    "FastAPI application stop count",
    labelnames=(),
    namespace=NAMESPACE,
    subsystem=SUBSYSTEM,
)


================================================
FILE: app/core/models.py
================================================
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass


================================================
FILE: app/main.py
================================================
import logging

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware

from app.auth.views import router as auth_router
from app.core import lifespan
from app.core.config import get_settings
from app.probe.views import router as probe_router

logger = logging.getLogger(__name__)


app = FastAPI(
    title="minimal fastapi postgres template",
    version="7.0.0",
    description="https://github.com/rafsaf/minimal-fastapi-postgres-template",
    openapi_url="/openapi.json",
    docs_url="/",
    lifespan=lifespan.lifespan,
)

app.include_router(auth_router, prefix="/auth", tags=["auth"])
app.include_router(probe_router, prefix="/probe", tags=["probe"])

# Sets all CORS enabled origins
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        str(origin).rstrip("/")
        for origin in get_settings().security.backend_cors_origins
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Guards against HTTP Host Header attacks
app.add_middleware(
    TrustedHostMiddleware,
    allowed_hosts=get_settings().security.allowed_hosts,
)


================================================
FILE: app/probe/__init__.py
================================================


================================================
FILE: app/probe/tests/__init__.py
================================================


================================================
FILE: app/probe/tests/test_views.py
================================================
from httpx import AsyncClient, codes

from app.main import app


async def test_live_probe(client: AsyncClient) -> None:
    response = await client.get(app.url_path_for("live_probe"))

    assert response.status_code == codes.OK
    assert response.text == '"ok"'


async def test_health_probe(client: AsyncClient) -> None:
    response = await client.get(app.url_path_for("health_probe"))

    assert response.status_code == codes.OK
    assert response.text == '"app and database ok"'


================================================
FILE: app/probe/views.py
================================================
import logging
import typing

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database_session import new_async_session

logger = logging.getLogger(__name__)

router = APIRouter()


@router.get("/live", response_model=str)
async def live_probe() -> typing.Literal["ok"]:
    return "ok"


@router.get("/health", response_model=str)
async def health_probe(
    _: AsyncSession = Depends(new_async_session),
) -> typing.Literal["app and database ok"]:
    return "app and database ok"


================================================
FILE: app/tests/auth.py
================================================
TESTS_USER_PASSWORD = "geralt"


================================================
FILE: app/tests/factories.py
================================================
import logging
from typing import TypeVar

from faker import Faker
from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory
from polyfactory.fields import Use

from app.auth.models import User
from app.auth.password import get_password_hash
from app.tests.auth import TESTS_USER_PASSWORD

logging.getLogger("factory").setLevel(logging.ERROR)


T = TypeVar("T")


logger = logging.getLogger(__name__)


class UserFactory(SQLAlchemyFactory[User]):
    email = Use(Faker().email)
    hashed_password = Use(lambda: get_password_hash(TESTS_USER_PASSWORD))


================================================
FILE: docker-compose.yml
================================================
services:
  postgres_db:
    restart: unless-stopped
    image: postgres:18
    volumes:
      - postgres_db:/var/lib/postgresql
    environment:
      - POSTGRES_DB=${DATABASE__DB}
      - POSTGRES_USER=${DATABASE__USERNAME}
      - POSTGRES_PASSWORD=${DATABASE__PASSWORD}
    env_file:
      - .env
    ports:
      - "${DATABASE__PORT}:5432"

volumes:
  postgres_db:


================================================
FILE: init.sh
================================================
#!/bin/bash
set -e

echo "Run migrations"
alembic upgrade head

# Run whatever CMD was passed
exec "$@"

================================================
FILE: pyproject.toml
================================================
[project]
authors = [{ name = "admin", email = "admin@example.com" }]
dependencies = [
    "alembic>=1.18.3",
    "asyncpg>=0.31.0",
    "bcrypt>=5.0.0",
    "fastapi>=0.128.0",
    "prometheus-client>=0.24.1",
    "pydantic-settings>=2.12.0",
    "pydantic[email]>=2.12.5",
    "pyjwt>=2.11.0",
    "python-multipart>=0.0.22",
    "sqlalchemy[asyncio]>=2.0.46",
]
description = "FastAPI project generated using minimal-fastapi-postgres-template."
name = "app"
requires-python = ">=3.14,<3.15"
version = "0.1.0-alpha"

[dependency-groups]
dev = [
    "coverage>=7.13.2",
    "freezegun>=1.5.5",
    "greenlet>=3.3.1",
    "httpx>=0.28.1",
    "mypy>=1.19.1",
    "polyfactory>=3.2.0",
    "pre-commit>=4.5.1",
    "pytest-asyncio>=1.3.0",
    "pytest-cov>=7.0.0",
    "pytest-xdist>=3.8.0",
    "pytest>=9.0.2",
    "ruff>=0.14.14",
    "uvicorn[standard]>=0.40.0",
]

[tool.uv]
package = false

[build-system]
build-backend = "hatchling.build"
requires = ["hatchling"]

[tool.pytest.ini_options]
addopts = "-vv -n auto --cov --cov-report xml --cov-report term-missing --cov-fail-under=100"
asyncio_default_test_loop_scope = "session"
asyncio_mode = "auto"
filterwarnings = [""]
testpaths = ["app"]

[tool.coverage.run]
concurrency = ["greenlet"]
omit = ["alembic/*", "app/tests/*", "conftest.py", "test_*.py"]
source = ["app"]

[tool.mypy]
exclude = [".venv", "alembic"]
files = "app/**"
python_version = "3.14"
strict = true

[tool.ruff]
target-version = "py314"

[tool.ruff.lint]
# pycodestyle, pyflakes, isort, pylint, pyupgrade
ignore = ["E501"]
select = ["E", "F", "I", "PL", "UP", "W"]
Download .txt
gitextract_d8hu4kk5/

├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── dev_build.yml
│       ├── tests.yml
│       └── type_check.yml
├── .gitignore
├── .pre-commit-config.yaml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── alembic/
│   ├── README
│   ├── env.py
│   ├── script.py.mako
│   └── versions/
│       └── 20260203_1616_initial_auth_683275eeb305.py
├── alembic.ini
├── app/
│   ├── __init__.py
│   ├── auth/
│   │   ├── api_messages.py
│   │   ├── dependencies.py
│   │   ├── jwt.py
│   │   ├── models.py
│   │   ├── password.py
│   │   ├── responses.py
│   │   ├── schemas.py
│   │   ├── tests/
│   │   │   ├── __init__.py
│   │   │   ├── test_jwt.py
│   │   │   ├── test_password.py
│   │   │   ├── test_view_delete_current_user.py
│   │   │   ├── test_view_login_access_token.py
│   │   │   ├── test_view_read_current_user.py
│   │   │   ├── test_view_refresh_token.py
│   │   │   ├── test_view_register_new_user.py
│   │   │   └── test_view_reset_current_user_password.py
│   │   └── views.py
│   ├── conftest.py
│   ├── core/
│   │   ├── __init__.py
│   │   ├── config.py
│   │   ├── database_session.py
│   │   ├── lifespan.py
│   │   ├── metrics.py
│   │   └── models.py
│   ├── main.py
│   ├── probe/
│   │   ├── __init__.py
│   │   ├── tests/
│   │   │   ├── __init__.py
│   │   │   └── test_views.py
│   │   └── views.py
│   └── tests/
│       ├── auth.py
│       └── factories.py
├── docker-compose.yml
├── init.sh
└── pyproject.toml
Download .txt
SYMBOL INDEX (92 symbols across 25 files)

FILE: alembic/env.py
  function get_database_uri (line 30) | def get_database_uri() -> str:
  function run_migrations_offline (line 34) | def run_migrations_offline() -> None:
  function do_run_migrations (line 60) | def do_run_migrations(connection: Connection | None) -> None:
  function run_migrations_online (line 69) | async def run_migrations_online() -> None:

FILE: alembic/versions/20260203_1616_initial_auth_683275eeb305.py
  function upgrade (line 20) | def upgrade():
  function downgrade (line 61) | def downgrade():

FILE: app/auth/dependencies.py
  function get_current_user (line 16) | async def get_current_user(

FILE: app/auth/jwt.py
  class JWTTokenPayload (line 12) | class JWTTokenPayload(BaseModel):
  class JWTToken (line 19) | class JWTToken(BaseModel):
  function create_jwt_token (line 24) | def create_jwt_token(user_id: str) -> JWTToken:
  function verify_jwt_token (line 44) | def verify_jwt_token(token: str) -> JWTTokenPayload:

FILE: app/auth/models.py
  class User (line 25) | class User(Base):
  class RefreshToken (line 48) | class RefreshToken(Base):

FILE: app/auth/password.py
  function verify_password (line 6) | def verify_password(plain_password: str, hashed_password: str) -> bool:
  function get_password_hash (line 12) | def get_password_hash(password: str) -> str:

FILE: app/auth/responses.py
  class BaseResponse (line 4) | class BaseResponse(BaseModel):
  class AccessTokenResponse (line 8) | class AccessTokenResponse(BaseResponse):
  class UserResponse (line 16) | class UserResponse(BaseResponse):

FILE: app/auth/schemas.py
  class RefreshTokenRequest (line 4) | class RefreshTokenRequest(BaseModel):
  class UserUpdatePasswordRequest (line 8) | class UserUpdatePasswordRequest(BaseModel):
  class UserCreateRequest (line 12) | class UserCreateRequest(BaseModel):
  class AccessTokenResponse (line 17) | class AccessTokenResponse(BaseModel):
  class UserResponse (line 27) | class UserResponse(BaseModel):

FILE: app/auth/tests/test_jwt.py
  function test_jwt_access_token_can_be_decoded_back_into_user_id (line 12) | def test_jwt_access_token_can_be_decoded_back_into_user_id() -> None:
  function test_jwt_payload_is_correct (line 21) | def test_jwt_payload_is_correct() -> None:
  function test_jwt_error_after_exp_time (line 34) | def test_jwt_error_after_exp_time() -> None:
  function test_jwt_error_before_iat_time (line 45) | def test_jwt_error_before_iat_time() -> None:
  function test_jwt_error_with_invalid_token (line 56) | def test_jwt_error_with_invalid_token() -> None:
  function test_jwt_error_with_invalid_issuer (line 63) | def test_jwt_error_with_invalid_issuer() -> None:
  function test_jwt_error_with_invalid_secret_key (line 75) | def test_jwt_error_with_invalid_secret_key() -> None:

FILE: app/auth/tests/test_password.py
  function test_hashed_password_is_verified (line 4) | def test_hashed_password_is_verified() -> None:
  function test_invalid_password_is_not_verified (line 9) | def test_invalid_password_is_not_verified() -> None:

FILE: app/auth/tests/test_view_delete_current_user.py
  function test_delete_current_user_status_code (line 10) | async def test_delete_current_user_status_code(
  function test_delete_current_user_is_deleted_in_db (line 22) | async def test_delete_current_user_is_deleted_in_db(

FILE: app/auth/tests/test_view_login_access_token.py
  function test_login_access_token_has_response_status_code (line 17) | async def test_login_access_token_has_response_status_code(
  function test_login_access_token_jwt_has_valid_token_type (line 32) | async def test_login_access_token_jwt_has_valid_token_type(
  function test_login_access_token_jwt_has_valid_expire_time (line 50) | async def test_login_access_token_jwt_has_valid_expire_time(
  function test_login_access_token_returns_valid_jwt_access_token (line 72) | async def test_login_access_token_returns_valid_jwt_access_token(
  function test_login_access_token_refresh_token_has_valid_expire_time (line 95) | async def test_login_access_token_refresh_token_has_valid_expire_time(
  function test_login_access_token_refresh_token_exists_in_db (line 117) | async def test_login_access_token_refresh_token_exists_in_db(
  function test_login_access_token_refresh_token_in_db_has_valid_fields (line 140) | async def test_login_access_token_refresh_token_in_db_has_valid_fields(
  function test_auth_access_token_fail_for_not_existing_user_with_message (line 166) | async def test_auth_access_token_fail_for_not_existing_user_with_message(
  function test_auth_access_token_fail_for_invalid_password_with_message (line 182) | async def test_auth_access_token_fail_for_invalid_password_with_message(

FILE: app/auth/tests/test_view_read_current_user.py
  function test_read_current_user_status_code (line 13) | async def test_read_current_user_status_code(
  function test_read_current_user_response (line 26) | async def test_read_current_user_response(
  function test_api_raise_401_on_jwt_decode_errors (line 42) | async def test_api_raise_401_on_jwt_decode_errors(
  function test_api_raise_401_on_jwt_expired_token (line 54) | async def test_api_raise_401_on_jwt_expired_token(
  function test_api_raise_401_on_jwt_user_deleted (line 70) | async def test_api_raise_401_on_jwt_user_deleted(

FILE: app/auth/tests/test_view_refresh_token.py
  function test_refresh_token_fails_with_message_when_token_does_not_exist (line 16) | async def test_refresh_token_fails_with_message_when_token_does_not_exist(
  function test_refresh_token_fails_with_message_when_token_is_expired (line 30) | async def test_refresh_token_fails_with_message_when_token_is_expired(
  function test_refresh_token_fails_with_message_when_token_is_used (line 54) | async def test_refresh_token_fails_with_message_when_token_is_used(
  function test_refresh_token_success_response_status_code (line 79) | async def test_refresh_token_success_response_status_code(
  function test_refresh_token_success_old_token_is_used (line 103) | async def test_refresh_token_success_old_token_is_used(
  function test_refresh_token_success_jwt_has_valid_token_type (line 131) | async def test_refresh_token_success_jwt_has_valid_token_type(
  function test_refresh_token_success_jwt_has_valid_expire_time (line 157) | async def test_refresh_token_success_jwt_has_valid_expire_time(
  function test_refresh_token_success_jwt_has_valid_access_token (line 187) | async def test_refresh_token_success_jwt_has_valid_access_token(
  function test_refresh_token_success_refresh_token_has_valid_expire_time (line 218) | async def test_refresh_token_success_refresh_token_has_valid_expire_time(
  function test_refresh_token_success_new_refresh_token_is_in_db (line 247) | async def test_refresh_token_success_new_refresh_token_is_in_db(

FILE: app/auth/tests/test_view_register_new_user.py
  function test_register_new_user_status_code (line 12) | async def test_register_new_user_status_code(
  function test_register_new_user_creates_record_in_db (line 26) | async def test_register_new_user_creates_record_in_db(
  function test_register_new_user_cannot_create_already_created_user (line 44) | async def test_register_new_user_cannot_create_already_created_user(

FILE: app/auth/tests/test_view_reset_current_user_password.py
  function test_reset_current_user_password_status_code (line 11) | async def test_reset_current_user_password_status_code(
  function test_reset_current_user_password_is_changed_in_db (line 24) | async def test_reset_current_user_password_is_changed_in_db(

FILE: app/auth/views.py
  function read_current_user (line 32) | async def read_current_user(
  function delete_current_user (line 43) | async def delete_current_user(
  function reset_current_user_password (line 56) | async def reset_current_user_password(
  function login_access_token (line 72) | async def login_access_token(
  function refresh_token (line 117) | async def refresh_token(
  function register_new_user (line 170) | async def register_new_user(

FILE: app/conftest.py
  function fixture_setup_new_test_database (line 30) | async def fixture_setup_new_test_database() -> AsyncGenerator[None]:
  function fixture_clean_get_settings_between_tests (line 80) | async def fixture_clean_get_settings_between_tests() -> AsyncGenerator[N...
  function fixture_session_with_rollback (line 87) | async def fixture_session_with_rollback(
  function fixture_client (line 123) | async def fixture_client(session: AsyncSession) -> AsyncGenerator[AsyncC...
  function fixture_default_user (line 131) | async def fixture_default_user(session: AsyncSession) -> AsyncGenerator[...
  function fixture_default_user_headers (line 138) | async def fixture_default_user_headers(default_user: User) -> dict[str, ...

FILE: app/core/config.py
  class Security (line 28) | class Security(BaseModel):
  class Database (line 42) | class Database(BaseModel):
  class Prometheus (line 50) | class Prometheus(BaseModel):
  class Settings (line 57) | class Settings(BaseSettings):
    method sqlalchemy_database_uri (line 66) | def sqlalchemy_database_uri(self) -> URL:
  function get_settings (line 84) | def get_settings() -> Settings:
  function logging_config (line 88) | def logging_config(log_level: str) -> None:

FILE: app/core/database_session.py
  function new_async_engine (line 23) | def new_async_engine(uri: URL) -> AsyncEngine:
  function new_async_session (line 38) | async def new_async_session() -> AsyncGenerator[AsyncSession]:  # pragma...
  function new_script_async_session (line 47) | async def new_script_async_session() -> AsyncGenerator[

FILE: app/core/lifespan.py
  function lifespan (line 16) | async def lifespan(_: FastAPI) -> AsyncGenerator[None]:  # pragma: no cover

FILE: app/core/models.py
  class Base (line 4) | class Base(DeclarativeBase):

FILE: app/probe/tests/test_views.py
  function test_live_probe (line 6) | async def test_live_probe(client: AsyncClient) -> None:
  function test_health_probe (line 13) | async def test_health_probe(client: AsyncClient) -> None:

FILE: app/probe/views.py
  function live_probe (line 15) | async def live_probe() -> typing.Literal["ok"]:
  function health_probe (line 20) | async def health_probe(

FILE: app/tests/factories.py
  class UserFactory (line 21) | class UserFactory(SQLAlchemyFactory[User]):
Condensed preview — 50 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (94K chars).
[
  {
    "path": ".github/dependabot.yml",
    "chars": 425,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: uv\n    directory: /\n    schedule:\n      interval: monthly\n    open-pull-reque"
  },
  {
    "path": ".github/workflows/dev_build.yml",
    "chars": 796,
    "preview": "name: dev-build\non:\n  workflow_run:\n    workflows: [\"tests\"]\n    branches: [main]\n    types:\n      - completed\n\n  workfl"
  },
  {
    "path": ".github/workflows/tests.yml",
    "chars": 1035,
    "preview": "name: tests\non:\n  push:\n    branches:\n      - \"**\"\n    tags-ignore:\n      - \"*.*\"\n\njobs:\n  tests:\n    runs-on: ubuntu-la"
  },
  {
    "path": ".github/workflows/type_check.yml",
    "chars": 738,
    "preview": "name: type-check\non:\n  push:\n    branches:\n      - \"**\"\n    tags-ignore:\n      - \"*.*\"\n\njobs:\n  type_check:\n    strategy"
  },
  {
    "path": ".gitignore",
    "chars": 1827,
    "preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n.env\n\n# C extensions\n*.so\n\n# Distribution / pa"
  },
  {
    "path": ".pre-commit-config.yaml",
    "chars": 351,
    "preview": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: check-yaml\n\n  - r"
  },
  {
    "path": "Dockerfile",
    "chars": 1197,
    "preview": "FROM python:3.14-slim-trixie AS base\n\nENV PYTHONUNBUFFERED=1\nWORKDIR /build\n\n# Create requirements.txt file\nFROM base AS"
  },
  {
    "path": "LICENSE",
    "chars": 1063,
    "preview": "MIT License\n\nCopyright (c) 2021 rafsaf\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
  },
  {
    "path": "Makefile",
    "chars": 947,
    "preview": "BIND_PORT ?= 8000\nBIND_HOST ?= localhost\n\n.PHONY: help\nhelp:  ## Print this help message\n\tgrep -E '^[\\.a-zA-Z_-]+:.*?## "
  },
  {
    "path": "README.md",
    "chars": 15548,
    "preview": "# Minimal FastAPI PostgreSQL template\n\n[![Live example](https://img.shields.io/badge/live%20example-https%3A%2F%2Fminima"
  },
  {
    "path": "alembic/README",
    "chars": 38,
    "preview": "Generic single-database configuration."
  },
  {
    "path": "alembic/env.py",
    "chars": 3076,
    "preview": "import asyncio\nfrom logging.config import fileConfig\n\nfrom sqlalchemy import Connection, engine_from_config, pool\nfrom s"
  },
  {
    "path": "alembic/script.py.mako",
    "chars": 494,
    "preview": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom ale"
  },
  {
    "path": "alembic/versions/20260203_1616_initial_auth_683275eeb305.py",
    "chars": 2150,
    "preview": "\"\"\"initial_auth\n\nRevision ID: 683275eeb305\nRevises:\nCreate Date: 2026-02-03 16:16:09.776977\n\n\"\"\"\n\nimport sqlalchemy as s"
  },
  {
    "path": "alembic.ini",
    "chars": 3337,
    "preview": "# A generic, single database configuration.\n\n[alembic]\n# path to migration scripts\nscript_location = alembic\n\n# template"
  },
  {
    "path": "app/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "app/auth/api_messages.py",
    "chars": 2371,
    "preview": "from typing import Any\n\nJWT_ERROR_USER_REMOVED = \"User removed\"\nPASSWORD_INVALID = \"Incorrect email or password\"\nREFRESH"
  },
  {
    "path": "app/auth/dependencies.py",
    "chars": 909,
    "preview": "from typing import Annotated\n\nfrom fastapi import Depends, HTTPException, status\nfrom fastapi.security import OAuth2Pass"
  },
  {
    "path": "app/auth/jwt.py",
    "chars": 2078,
    "preview": "import time\n\nimport jwt\nfrom fastapi import HTTPException, status\nfrom pydantic import BaseModel\n\nfrom app.core.config i"
  },
  {
    "path": "app/auth/models.py",
    "chars": 1974,
    "preview": "# SQL Alchemy models declaration.\n# https://docs.sqlalchemy.org/en/20/orm/quickstart.html#declare-models\n# mapped_column"
  },
  {
    "path": "app/auth/password.py",
    "chars": 463,
    "preview": "import bcrypt\n\nfrom app.core.config import get_settings\n\n\ndef verify_password(plain_password: str, hashed_password: str)"
  },
  {
    "path": "app/auth/responses.py",
    "chars": 384,
    "preview": "from pydantic import BaseModel, ConfigDict, EmailStr\n\n\nclass BaseResponse(BaseModel):\n    model_config = ConfigDict(from"
  },
  {
    "path": "app/auth/schemas.py",
    "chars": 602,
    "preview": "from pydantic import BaseModel, ConfigDict, EmailStr\n\n\nclass RefreshTokenRequest(BaseModel):\n    refresh_token: str\n\n\ncl"
  },
  {
    "path": "app/auth/tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "app/auth/tests/test_jwt.py",
    "chars": 2543,
    "preview": "import time\n\nimport pytest\nfrom fastapi import HTTPException\nfrom freezegun import freeze_time\nfrom pydantic import Secr"
  },
  {
    "path": "app/auth/tests/test_password.py",
    "chars": 382,
    "preview": "from app.auth.password import get_password_hash, verify_password\n\n\ndef test_hashed_password_is_verified() -> None:\n    p"
  },
  {
    "path": "app/auth/tests/test_view_delete_current_user.py",
    "chars": 945,
    "preview": "from fastapi import status\nfrom httpx import AsyncClient\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio impor"
  },
  {
    "path": "app/auth/tests/test_view_login_access_token.py",
    "chars": 6174,
    "preview": "import time\n\nfrom fastapi import status\nfrom freezegun import freeze_time\nfrom httpx import AsyncClient\nfrom sqlalchemy "
  },
  {
    "path": "app/auth/tests/test_view_read_current_user.py",
    "chars": 2568,
    "preview": "from fastapi import status\nfrom freezegun import freeze_time\nfrom httpx import AsyncClient\nfrom sqlalchemy import delete"
  },
  {
    "path": "app/auth/tests/test_view_refresh_token.py",
    "chars": 7146,
    "preview": "import time\n\nfrom fastapi import status\nfrom freezegun import freeze_time\nfrom httpx import AsyncClient\nfrom sqlalchemy "
  },
  {
    "path": "app/auth/tests/test_view_register_new_user.py",
    "chars": 1584,
    "preview": "from fastapi import status\nfrom httpx import AsyncClient\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio"
  },
  {
    "path": "app/auth/tests/test_view_reset_current_user_password.py",
    "chars": 1162,
    "preview": "from fastapi import status\nfrom httpx import AsyncClient\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio impor"
  },
  {
    "path": "app/auth/views.py",
    "chars": 6090,
    "preview": "import secrets\nimport time\n\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom fastapi.security import O"
  },
  {
    "path": "app/conftest.py",
    "chars": 4804,
    "preview": "import asyncio\nimport os\nfrom collections.abc import AsyncGenerator\n\nimport alembic.command\nimport alembic.config\nimport"
  },
  {
    "path": "app/core/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "app/core/config.py",
    "chars": 3445,
    "preview": "# File with environment variables and general configuration logic.\n# Env variables are combined in nested groups like \"S"
  },
  {
    "path": "app/core/database_session.py",
    "chars": 1818,
    "preview": "# SQLAlchemy async engine and sessions tools\n#\n# https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html\n#\n# for p"
  },
  {
    "path": "app/core/lifespan.py",
    "chars": 1396,
    "preview": "import asyncio\nimport logging\nfrom collections.abc import AsyncGenerator\nfrom contextlib import asynccontextmanager\n\nimp"
  },
  {
    "path": "app/core/metrics.py",
    "chars": 409,
    "preview": "import prometheus_client\n\nNAMESPACE = \"org\"\nSUBSYSTEM = \"app\"\n\nAPP_STARTED = prometheus_client.Counter(\n    \"app_started"
  },
  {
    "path": "app/core/models.py",
    "chars": 83,
    "preview": "from sqlalchemy.orm import DeclarativeBase\n\n\nclass Base(DeclarativeBase):\n    pass\n"
  },
  {
    "path": "app/main.py",
    "chars": 1177,
    "preview": "import logging\n\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.t"
  },
  {
    "path": "app/probe/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "app/probe/tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "app/probe/tests/test_views.py",
    "chars": 488,
    "preview": "from httpx import AsyncClient, codes\n\nfrom app.main import app\n\n\nasync def test_live_probe(client: AsyncClient) -> None:"
  },
  {
    "path": "app/probe/views.py",
    "chars": 537,
    "preview": "import logging\nimport typing\n\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfr"
  },
  {
    "path": "app/tests/auth.py",
    "chars": 31,
    "preview": "TESTS_USER_PASSWORD = \"geralt\"\n"
  },
  {
    "path": "app/tests/factories.py",
    "chars": 565,
    "preview": "import logging\nfrom typing import TypeVar\n\nfrom faker import Faker\nfrom polyfactory.factories.sqlalchemy_factory import "
  },
  {
    "path": "docker-compose.yml",
    "chars": 370,
    "preview": "services:\n  postgres_db:\n    restart: unless-stopped\n    image: postgres:18\n    volumes:\n      - postgres_db:/var/lib/po"
  },
  {
    "path": "init.sh",
    "chars": 103,
    "preview": "#!/bin/bash\nset -e\n\necho \"Run migrations\"\nalembic upgrade head\n\n# Run whatever CMD was passed\nexec \"$@\""
  },
  {
    "path": "pyproject.toml",
    "chars": 1593,
    "preview": "[project]\nauthors = [{ name = \"admin\", email = \"admin@example.com\" }]\ndependencies = [\n    \"alembic>=1.18.3\",\n    \"async"
  }
]

About this extraction

This page contains the full source code of the rafsaf/minimal-fastapi-postgres-template GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 50 files (85.2 KB), approximately 21.9k tokens, and a symbol index with 92 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!