[
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: uv\n    directory: /\n    schedule:\n      interval: monthly\n    open-pull-requests-limit: 1\n    allow:\n      - dependency-type: \"all\"\n    groups:\n      all-dependencies:\n        patterns:\n          - \"*\"\n\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: monthly\n\n  - package-ecosystem: docker\n    directory: /\n    schedule:\n      interval: monthly\n"
  },
  {
    "path": ".github/workflows/dev_build.yml",
    "content": "name: dev-build\non:\n  workflow_run:\n    workflows: [\"tests\"]\n    branches: [main]\n    types:\n      - completed\n\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: \"Docker image tag\"\n        required: true\n        default: \"latest\"\n\nenv:\n  IMAGE_TAG: ${{ github.event.inputs.tag || 'latest' }}\n\njobs:\n  dev_build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USER }}\n          password: ${{ secrets.DOCKER_PASS }}\n\n      - name: Build and push image\n        uses: docker/build-push-action@v6\n        with:\n          file: Dockerfile\n          push: true\n          tags: rafsaf/minimal-fastapi-postgres-template:${{ env.IMAGE_TAG }}\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: tests\non:\n  push:\n    branches:\n      - \"**\"\n    tags-ignore:\n      - \"*.*\"\n\njobs:\n  tests:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:18\n        env:\n          POSTGRES_PASSWORD: postgres\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n        ports:\n          - 5432:5432\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: \"Set up Python\"\n        uses: actions/setup-python@v6\n        with:\n          python-version-file: \"pyproject.toml\"\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"0.9.2\"\n          enable-cache: true\n\n      - name: Install the project\n        run: uv sync --locked --all-extras --dev\n        shell: bash\n\n      - name: Run tests\n        env:\n          SECURITY__JWT_SECRET_KEY: very-not-secret\n          DATABASE__HOSTNAME: localhost\n          DATABASE__PASSWORD: postgres\n        run: |\n          uv run pytest\n"
  },
  {
    "path": ".github/workflows/type_check.yml",
    "content": "name: type-check\non:\n  push:\n    branches:\n      - \"**\"\n    tags-ignore:\n      - \"*.*\"\n\njobs:\n  type_check:\n    strategy:\n      matrix:\n        check: [\"ruff check\", \"mypy --check\", \"ruff format --check\"]\n\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: \"Set up Python\"\n        uses: actions/setup-python@v6\n        with:\n          python-version-file: \"pyproject.toml\"\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"0.9.2\"\n          enable-cache: true\n\n      - name: Install the project\n        run: uv sync --locked --all-extras --dev\n        shell: bash\n\n      - name: Run ${{ matrix.check }}\n        run: |\n          uv run ${{ matrix.check }} .\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n.env\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\npip-wheel-metadata/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\nlog.txt\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n.python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# ruff\n.ruff_cache\n\n# Pyre type checker\n.pyre/"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: check-yaml\n\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.15.0\n    hooks:\n      - id: ruff-format\n\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.15.0\n    hooks:\n      - id: ruff-check\n        args: [--fix]\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.14-slim-trixie AS base\n\nENV PYTHONUNBUFFERED=1\nWORKDIR /build\n\n# Create requirements.txt file\nFROM base AS uv\nCOPY --from=ghcr.io/astral-sh/uv:0.9.2 /uv /uvx /bin/\nCOPY uv.lock pyproject.toml ./\nRUN uv export --no-dev --no-hashes -o /requirements.txt --no-install-workspace --frozen\nRUN uv export --only-group dev --no-hashes -o /requirements-dev.txt --no-install-workspace --frozen\n\nFROM base AS final\nCOPY --from=uv /requirements.txt .\n\n# Create venv, add it to path and install requirements\nRUN python -m venv /venv\nENV PATH=\"/venv/bin:$PATH\"\nRUN pip install -r requirements.txt\n\n# Install uvicorn server\nRUN pip install uvicorn[standard]\n\n# Copy the rest of app\nCOPY app app\nCOPY alembic alembic\nCOPY alembic.ini .\nCOPY pyproject.toml .\nCOPY init.sh .\n\n# Expose port 8000 for app and optional 9090 for prometheus metrics\nEXPOSE 8000\nEXPOSE 9090\n\n# Make the init script executable\nRUN chmod +x ./init.sh\n\n# Set ENTRYPOINT to always run init.sh\nENTRYPOINT [\"./init.sh\"]\n\n# Set CMD to uvicorn\n# /venv/bin/uvicorn is used because from entrypoint script PATH is new\nCMD [\"/venv/bin/uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\", \"--workers\", \"1\", \"--loop\", \"uvloop\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 rafsaf\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "BIND_PORT ?= 8000\nBIND_HOST ?= localhost\n\n.PHONY: help\nhelp:  ## Print this help message\n\tgrep -E '^[\\.a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = \":.*?## \"}; {printf \"\\033[36m%-30s\\033[0m %s\\n\", $$1, $$2}'\n\n.env:  ## Ensure there is env file or create one\n\techo \"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\n\n.PHONY: local-setup\nlocal-setup:  ## Setup local postgres database\n\tdocker compose up -d\n\n.PHONY: up\nup: local-setup  ## Run FastAPI development server\n\tuv run alembic upgrade head\n\tuv run uvicorn app.main:app --reload --host $(BIND_HOST) --port $(BIND_PORT)\n\n.PHONY: run\nrun: up  ## Alias for `up`\n\n.PHONY: down\ndown:  ## Stop database\n\tdocker compose down\n\n.PHONY: test\ntest: local-setup  ## Run unit tests\n\tuv run pytest .\n\n.PHONY: lint\nlint: local-setup  ## Run all linters\n\tuv run pre-commit run -a\n\tuv run mypy .\n"
  },
  {
    "path": "README.md",
    "content": "# Minimal FastAPI PostgreSQL template\n\n[![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/)\n[![License](https://img.shields.io/github/license/rafsaf/minimal-fastapi-postgres-template)](https://github.com/rafsaf/minimal-fastapi-postgres-template/blob/main/LICENSE)\n[![Python 3.14](https://img.shields.io/badge/python-3.14-blue)](https://docs.python.org/3/whatsnew/3.14.html)\n[![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)\n[![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)\n\n_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._\n\n- [Minimal FastAPI PostgreSQL template](#minimal-fastapi-postgresql-template)\n  - [About](#about)\n  - [Features](#features)\n  - [Quickstart](#quickstart)\n    - [1. Create repository from a template](#1-create-repository-from-a-template)\n    - [2. Install dependencies with uv](#2-install-dependencies-with-uv)\n    - [3. Run app](#3-run-app)\n    - [4. Activate pre-commit](#4-activate-pre-commit)\n    - [5. Running tests](#5-running-tests)\n  - [Step by step example - POST and GET endpoints](#step-by-step-example---post-and-get-endpoints)\n    - [1. Create new app](#1-create-new-app)\n    - [2. Create SQLAlchemy model](#2-create-sqlalchemy-model)\n    - [3. Import new models.py file in alembic env.py](#3-import-new-modelspy-file-in-alembic-envpy)\n    - [4. Create and apply alembic migration](#4-create-and-apply-alembic-migration)\n    - [5. Create request and response schemas](#5-create-request-and-response-schemas)\n    - [6. Create endpoints](#6-create-endpoints)\n    - [7. Add Pet model to tests factories](#7-add-pet-model-to-tests-factories)\n    - [8. Create new test file](#8-create-new-test-file)\n    - [9. Write tests](#9-write-tests)\n  - [Design choices](#design-choices)\n    - [Dockerfile](#dockerfile)\n    - [Registration](#registration)\n    - [Delete user endpoint](#delete-user-endpoint)\n    - [JWT and refresh tokens](#jwt-and-refresh-tokens)\n    - [Writing scripts / cron](#writing-scripts--cron)\n    - [Docs URL](#docs-url)\n    - [CORS](#cors)\n    - [Allowed Hosts](#allowed-hosts)\n  - [License](#license)\n\n## About\n\nIf 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/).\n\nEnjoy!\n\n## Features\n\n- [x] Template repository.\n- [x] [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) 2.0, async queries, best possible autocompletion support.\n- [x] PostgreSQL 18 database under [asyncpg](https://github.com/MagicStack/asyncpg) interface.\n- [x] Full [Alembic](https://github.com/alembic/alembic) migrations setup (also in unit tests).\n- [x] Secure and tested setup for [PyJWT](https://github.com/jpadilla/pyjwt) and [bcrypt](https://github.com/pyca/bcrypt).\n- [x] Ready to go Dockerfile with [uvicorn](https://www.uvicorn.org/) webserver.\n- [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).\n- [x] Perfect pytest asynchronous test setup and full coverage.\n\n![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)\n\n## Quickstart\n\n### 1. Create repository from a template\n\nSee [docs](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template) or just use git clone.\n\n### 2. Install dependencies with [uv](https://docs.astral.sh/uv/getting-started/installation/)\n\n```bash\ncd your_project_name\n\nuv sync\n\n```\n\nUv should automatically install Python version currently required by template (>=3.14) or use existing Python installation if you already have it.\n\n### 3. Run app\n\n```bash\nmake up\n\n```\n\nRefer to `Makefile` to see shortcut (`apt install build-essential` - on linux)\n\nIf you want to work without it, this should do:\n\n```bash\ndocker compose up -d\n\nalembic upgrade head\n\nuvicorn app.main:app --reload\n\n```\n\nYou 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.\n\n### 4. Activate pre-commit\n\n[pre-commit](https://pre-commit.com/) is de facto standard now for pre push activities like isort or black or its nowadays replacement ruff.\n\nRefer to `.pre-commit-config.yaml` file to see my current opinionated choices.\n\n```bash\n# Shortcut\nmake lint\n\n```\n\nFull commands\n\n```bash\n# Install pre-commit\npre-commit install --install-hooks\n\n# Run on all files\npre-commit run --all-files\n\n```\n\n### 5. Running tests\n\nNote, 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.\n\nFor 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`.\n\nMoreover, there is coverage pytest plugin with required code coverage level 100%.\n\n```bash\n# see all pytest configuration flags in pyproject.toml\npytest\n\n# or \nmake test\n\n```\n\n## Step by step example - POST and GET endpoints\n\nI 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:\n\n- `POST` endpoint `/pets/create` for creating `Pets` with relation to currently logged `User`\n- `GET` endpoint `/pets/me` for fetching all user's pets.\n\n### 1. Create new app\n\nAdd `app/pets` folder and `app/pets/__init__.py`.\n\n### 2. Create SQLAlchemy model\n\nWe will add `Pet` model to `app/pets/models.py`.\n\n```python\n# app/pets/models.py\n\nimport sqlalchemy as sa\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom app.core.models import Base\n\n\nclass Pet(Base):\n    __tablename__ = \"pets_pet\"\n\n    id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True)\n    user_id: Mapped[str] = mapped_column(\n        sa.ForeignKey(\"auth_user.user_id\", ondelete=\"CASCADE\"),\n    )\n    pet_name: Mapped[str] = mapped_column(sa.String(50), nullable=False)\n\n```\n\nNote, 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.\n\n### 3. Import new models.py file in alembic env.py\n\nWithout this step, alembic won't be able to follow changes in new `models.py` file. In `alembic/env.py` import new file\n\n```python\n# alembic/env.py\n\n(...) \n# import other models here\nimport app.pets.models  # noqa\n\n(...)\n\n```\n\n### 4. Create and apply alembic migration\n\n```bash\n### Use below commands in root folder in virtualenv ###\n\n# if you see FAILED: Target database is not up to date.\n# first use alembic upgrade head\n\n# Create migration with alembic revision\nalembic revision --autogenerate -m \"create_pet_model\"\n\n\n# File similar to \"2022050949_create_pet_model_44b7b689ea5f.py\" should appear in `/alembic/versions` folder\n\n\n# Apply migration using alembic upgrade\nalembic upgrade head\n\n# (...)\n# INFO  [alembic.runtime.migration] Running upgrade d1252175c146 -> 44b7b689ea5f, create_pet_model\n```\n\nPS. Note, alembic is configured in a way that it work with async setup and also detects specific column changes if using `--autogenerate` flag.\n\n### 5. Create request and response schemas\n\n```python\n# app/pets/schemas.py\n\nfrom pydantic import BaseModel, ConfigDict\n\n\nclass PetCreateRequest(BaseModel):\n    pet_name: str\n\n\nclass PetResponse(BaseModel):\n    id: int\n    pet_name: str\n    user_id: str\n\n    model_config = ConfigDict(from_attributes=True)\n\n```\n\n### 6. Create endpoints\n\n```python\n# app/pets/views.py\n\nfrom fastapi import APIRouter, Depends, status\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth.dependencies import get_current_user\nfrom app.auth.models import User\nfrom app.core import database_session\nfrom app.pets.models import Pet\nfrom app.pets.schemas import PetCreateRequest, PetResponse\n\nrouter = APIRouter()\n\n\n@router.post(\n    \"/create\",\n    response_model=PetResponse,\n    status_code=status.HTTP_201_CREATED,\n    description=\"Creates new pet. Only for logged users.\",\n)\nasync def create_new_pet(\n    data: PetCreateRequest,\n    session: AsyncSession = Depends(database_session.new_async_session),\n    current_user: User = Depends(get_current_user),\n) -> Pet:\n    new_pet = Pet(user_id=current_user.user_id, pet_name=data.pet_name)\n\n    session.add(new_pet)\n    await session.commit()\n\n    return new_pet\n\n\n@router.get(\n    \"/me\",\n    response_model=list[PetResponse],\n    status_code=status.HTTP_200_OK,\n    description=\"Get list of pets for currently logged user.\",\n)\nasync def get_all_my_pets(\n    session: AsyncSession = Depends(database_session.new_async_session),\n    current_user: User = Depends(get_current_user),\n) -> list[Pet]:\n    pets = await session.scalars(\n        select(Pet).where(Pet.user_id == current_user.user_id).order_by(Pet.pet_name)\n    )\n\n    return list(pets.all())\n\n```\n\nNow we need to add newly created router to `main.py` app.\n\n```python\n# main.py\n\n(...)\n\nfrom app.pets.views import router as pets_router\n\n(...)\n\napp.include_router(pets_router, prefix=\"/pets\", tags=[\"pets\"])\n\n```\n\n### 7. Add Pet model to tests factories\n\nFile `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).\n\n```python\n# app/tests/factories.py\n(...)\n\nfrom app.pets.models import Pet\n\n(...)\n\nclass PetFactory(SQLAlchemyFactory[Pet]):\n    pet_name = Use(Faker().first_name)\n\n```\n\n### 8. Create new test file\n\nCreate folder `app/pet/tests` and inside files `__init__.py` and eg. `test_pets_views.py`.\n\n### 9. Write tests\n\nWe will write two really simple tests into new file `test_pets_views.py`\n\n```python\n# app/pet/tests/test_pets_views.py\n\nfrom fastapi import status\nfrom httpx import AsyncClient\n\nfrom app.auth.models import User\nfrom app.main import app\nfrom app.tests.factories import PetFactory\n\n\nasync def test_create_new_pet(\n    client: AsyncClient, default_user_headers: dict[str, str], default_user: User\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"create_new_pet\"),\n        headers=default_user_headers,\n        json={\"pet_name\": \"Tadeusz\"},\n    )\n    assert response.status_code == status.HTTP_201_CREATED\n\n    result = response.json()\n    assert result[\"user_id\"] == default_user.user_id\n    assert result[\"pet_name\"] == \"Tadeusz\"\n\n\nasync def test_get_all_my_pets(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n    default_user: User,\n) -> None:\n    pet1 = await PetFactory.create_async(\n        user_id=default_user.user_id, pet_name=\"Alfred\"\n    )\n    pet2 = await PetFactory.create_async(\n        user_id=default_user.user_id, pet_name=\"Tadeusz\"\n    )\n\n    response = await client.get(\n        app.url_path_for(\"get_all_my_pets\"),\n        headers=default_user_headers,\n    )\n    assert response.status_code == status.HTTP_200_OK\n\n    assert response.json() == [\n        {\n            \"user_id\": pet1.user_id,\n            \"pet_name\": pet1.pet_name,\n            \"id\": pet1.id,\n        },\n        {\n            \"user_id\": pet2.user_id,\n            \"pet_name\": pet2.pet_name,\n            \"id\": pet2.id,\n        },\n    ]\n\n```\n\n## Design choices\n\nThere are couple decisions to make and changes that can/should be done after fork. I try to describe below things I consider most opinionated.\n\n### Dockerfile\n\nThis 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).\n\n### Registration\n\nIs open. You would probably want to either remove it altogether or change.\n\n### Delete user endpoint\n\nRethink `delete_current_user`, maybe you don't need it.\n\n### JWT and refresh tokens\n\nBy 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).\n\n### Writing scripts / cron\n\nVery 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())`.\n\n### Docs URL\n\nDocs page is simply `/` (by default in FastAPI it is `/docs`). You can change it completely for the project, just as title, version, etc.\n\n```python\napp = FastAPI(\n    title=\"minimal fastapi postgres template\",\n    version=\"7.0.0\",\n    description=\"https://github.com/rafsaf/minimal-fastapi-postgres-template\",\n    openapi_url=\"/openapi.json\",\n    docs_url=\"/\",\n)\n```\n\n### CORS\n\nIf 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`.\n\n```python\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[str(origin) for origin in config.settings.BACKEND_CORS_ORIGINS],\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n```\n\n### Allowed Hosts\n\nThis 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\"`\n\n```python\napp.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS)\n```\n\n## License\n\nThe 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.\n"
  },
  {
    "path": "alembic/README",
    "content": "Generic single-database configuration."
  },
  {
    "path": "alembic/env.py",
    "content": "import asyncio\nfrom logging.config import fileConfig\n\nfrom sqlalchemy import Connection, engine_from_config, pool\nfrom sqlalchemy.ext.asyncio import AsyncEngine\n\nfrom alembic import context\nfrom app.core.config import get_settings\n\n# this is the Alembic Config object, which provides\n# access to the values within the .ini file in use.\nconfig = context.config\n\n# Interpret the config file for Python logging.\n# This line sets up loggers basically.\nfileConfig(config.config_file_name)  # type: ignore\n\n# add your model's MetaData object here\n# for 'autogenerate' support\n# from myapp import mymodel\n# target_metadata = mymodel.Base.metadata\nfrom app.core.models import Base  # noqa\n\ntarget_metadata = Base.metadata\n\n# import other models here\nimport app.auth.models  # noqa\n\n\ndef get_database_uri() -> str:\n    return get_settings().sqlalchemy_database_uri.render_as_string(hide_password=False)\n\n\ndef run_migrations_offline() -> None:\n    \"\"\"Run migrations in 'offline' mode.\n\n    This configures the context with just a URL\n    and not an Engine, though an Engine is acceptable\n    here as well.  By skipping the Engine creation\n    we don't even need a DBAPI to be available.\n\n    Calls to context.execute() here emit the given string to the\n    script output.\n\n    \"\"\"\n    url = get_database_uri()\n    context.configure(\n        url=url,\n        target_metadata=target_metadata,\n        literal_binds=True,\n        dialect_opts={\"paramstyle\": \"named\"},\n        compare_type=True,\n        compare_server_default=True,\n    )\n\n    with context.begin_transaction():\n        context.run_migrations()\n\n\ndef do_run_migrations(connection: Connection | None) -> None:\n    context.configure(\n        connection=connection, target_metadata=target_metadata, compare_type=True\n    )\n\n    with context.begin_transaction():\n        context.run_migrations()\n\n\nasync def run_migrations_online() -> None:\n    \"\"\"Run migrations in 'online' mode.\n\n    In this scenario we need to create an Engine\n    and associate a connection with the context.\n\n    \"\"\"\n    configuration = config.get_section(config.config_ini_section)\n    assert configuration\n    configuration[\"sqlalchemy.url\"] = get_database_uri()\n    connectable = AsyncEngine(\n        engine_from_config(\n            configuration,\n            prefix=\"sqlalchemy.\",\n            poolclass=pool.NullPool,\n            future=True,\n        )\n    )\n    async with connectable.connect() as connection:\n        await connection.run_sync(do_run_migrations)\n\n\nif context.is_offline_mode():\n    run_migrations_offline()\nelse:\n    try:\n        loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()\n    except RuntimeError:\n        loop = None\n\n    if loop and loop.is_running():\n        # pytest-asyncio or other test runner is running the event loop\n        # so we need to use run_coroutine_threadsafe\n        future = asyncio.run_coroutine_threadsafe(run_migrations_online(), loop)\n        future.result(timeout=15)\n    else:\n        # no event loop is running, safe to use asyncio.run\n        asyncio.run(run_migrations_online())\n"
  },
  {
    "path": "alembic/script.py.mako",
    "content": "\"\"\"${message}\n\nRevision ID: ${up_revision}\nRevises: ${down_revision | comma,n}\nCreate Date: ${create_date}\n\n\"\"\"\nfrom alembic import op\nimport sqlalchemy as sa\n${imports if imports else \"\"}\n\n# revision identifiers, used by Alembic.\nrevision = ${repr(up_revision)}\ndown_revision = ${repr(down_revision)}\nbranch_labels = ${repr(branch_labels)}\ndepends_on = ${repr(depends_on)}\n\n\ndef upgrade():\n    ${upgrades if upgrades else \"pass\"}\n\n\ndef downgrade():\n    ${downgrades if downgrades else \"pass\"}\n"
  },
  {
    "path": "alembic/versions/20260203_1616_initial_auth_683275eeb305.py",
    "content": "\"\"\"initial_auth\n\nRevision ID: 683275eeb305\nRevises:\nCreate Date: 2026-02-03 16:16:09.776977\n\n\"\"\"\n\nimport sqlalchemy as sa\n\nfrom alembic import op\n\n# revision identifiers, used by Alembic.\nrevision = \"683275eeb305\"\ndown_revision = None\nbranch_labels = None\ndepends_on = None\n\n\ndef upgrade():\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.create_table(\n        \"auth_user\",\n        sa.Column(\"user_id\", sa.String(length=36), nullable=False),\n        sa.Column(\"email\", sa.String(length=256), nullable=False),\n        sa.Column(\"hashed_password\", sa.String(length=128), nullable=False),\n        sa.Column(\n            \"created_at\",\n            sa.DateTime(timezone=True),\n            server_default=sa.text(\"now()\"),\n            nullable=False,\n        ),\n        sa.Column(\n            \"updated_at\",\n            sa.DateTime(timezone=True),\n            server_default=sa.text(\"now()\"),\n            nullable=False,\n        ),\n        sa.PrimaryKeyConstraint(\"user_id\"),\n    )\n    op.create_index(op.f(\"ix_auth_user_email\"), \"auth_user\", [\"email\"], unique=True)\n    op.create_table(\n        \"auth_refresh_token\",\n        sa.Column(\"id\", sa.BigInteger(), nullable=False),\n        sa.Column(\"refresh_token\", sa.String(length=512), nullable=False),\n        sa.Column(\"used\", sa.Boolean(), nullable=False),\n        sa.Column(\"exp\", sa.BigInteger(), nullable=False),\n        sa.Column(\"user_id\", sa.String(length=36), nullable=False),\n        sa.ForeignKeyConstraint([\"user_id\"], [\"auth_user.user_id\"], ondelete=\"CASCADE\"),\n        sa.PrimaryKeyConstraint(\"id\"),\n    )\n    op.create_index(\n        op.f(\"ix_auth_refresh_token_refresh_token\"),\n        \"auth_refresh_token\",\n        [\"refresh_token\"],\n        unique=True,\n    )\n    # ### end Alembic commands ###\n\n\ndef downgrade():\n    # ### commands auto generated by Alembic - please adjust! ###\n    op.drop_index(\n        op.f(\"ix_auth_refresh_token_refresh_token\"), table_name=\"auth_refresh_token\"\n    )\n    op.drop_table(\"auth_refresh_token\")\n    op.drop_index(op.f(\"ix_auth_user_email\"), table_name=\"auth_user\")\n    op.drop_table(\"auth_user\")\n    # ### end Alembic commands ###\n"
  },
  {
    "path": "alembic.ini",
    "content": "# A generic, single database configuration.\n\n[alembic]\n# path to migration scripts\nscript_location = alembic\n\n# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s\n# Uncomment the line below if you want the files to be prepended with date and time\nfile_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s_%%(rev)s\n\n# sys.path path, will be prepended to sys.path if present.\n# defaults to the current working directory.\nprepend_sys_path = .\n\n# timezone to use when rendering the date within the migration file\n# as well as the filename.\n# If specified, requires the python>=3.9 or backports.zoneinfo library.\n# Any required deps can installed by adding `alembic[tz]` to the pip requirements\n# string value is passed to ZoneInfo()\n# leave blank for localtime\n# timezone =\n\n# max length of characters to apply to the\n# \"slug\" field\ntruncate_slug_length = 40\n\n# set to 'true' to run the environment during\n# the 'revision' command, regardless of autogenerate\n# revision_environment = false\n\n# set to 'true' to allow .pyc and .pyo files without\n# a source .py file to be detected as revisions in the\n# versions/ directory\n# sourceless = false\n\n# version location specification; This defaults\n# to ${script_location}/versions.  When using multiple version\n# directories, initial revisions must be specified with --version-path.\n# The path separator used here should be the separator specified by \"version_path_separator\" below.\n# version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions\n\n# version path separator; As mentioned above, this is the character used to split\n# version_locations. The default within new alembic.ini files is \"os\", which uses os.pathsep.\n# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.\n# Valid values for version_path_separator are:\n#\n# version_path_separator = :\n# version_path_separator = ;\n# version_path_separator = space\nversion_path_separator = os  # Use os.pathsep. Default configuration used for new projects.\npath_separator=os\n# set to 'true' to search source files recursively\n# in each \"version_locations\" directory\n# new in Alembic version 1.10\n# recursive_version_locations = false\n\n# the output encoding used when revision files\n# are written from script.py.mako\n# output_encoding = utf-8\n\nsqlalchemy.url = driver://user:pass@localhost/dbname\n\n[post_write_hooks]\nhooks = pre_commit\npre_commit.type = console_scripts\npre_commit.entrypoint = pre-commit\npre_commit.options = run --files REVISION_SCRIPT_FILENAME\n# This section defines scripts or Python functions that are run\n# on newly generated revision scripts.  See the documentation for further\n# detail and examples\n\n# format using \"black\" - use the console_scripts runner,\n# against the \"black\" entrypoint\n\n\n# Logging configuration\n[loggers]\nkeys = root,sqlalchemy,alembic\n\n[handlers]\nkeys = console\n\n[formatters]\nkeys = generic\n\n[logger_root]\nlevel = WARN\nhandlers = console\nqualname =\n\n[logger_sqlalchemy]\nlevel = WARN\nhandlers =\nqualname = sqlalchemy.engine\n\n[logger_alembic]\nlevel = INFO\nhandlers =\nqualname = alembic\n\n[handler_console]\nclass = StreamHandler\nargs = (sys.stderr,)\nlevel = NOTSET\nformatter = generic\n\n[formatter_generic]\nformat = %(levelname)-5.5s [%(name)s] %(message)s\ndatefmt = %H:%M:%S"
  },
  {
    "path": "app/__init__.py",
    "content": ""
  },
  {
    "path": "app/auth/api_messages.py",
    "content": "from typing import Any\n\nJWT_ERROR_USER_REMOVED = \"User removed\"\nPASSWORD_INVALID = \"Incorrect email or password\"\nREFRESH_TOKEN_NOT_FOUND = \"Refresh token not found\"\nREFRESH_TOKEN_EXPIRED = \"Refresh token expired\"\nREFRESH_TOKEN_ALREADY_USED = \"Refresh token already used\"\nEMAIL_ADDRESS_ALREADY_USED = \"Cannot use this email address\"\n\n\nUNAUTHORIZED_RESPONSES: dict[int | str, dict[str, Any]] = {\n    401: {\n        \"description\": \"No `Authorization` access token header, token is invalid or user removed\",\n        \"content\": {\n            \"application/json\": {\n                \"examples\": {\n                    \"not authenticated\": {\n                        \"summary\": \"No authorization token header\",\n                        \"value\": {\"detail\": \"Not authenticated\"},\n                    },\n                    \"invalid token\": {\n                        \"summary\": \"Token validation failed, decode failed, it may be expired or malformed\",\n                        \"value\": {\"detail\": \"Token invalid: {detailed error msg}\"},\n                    },\n                    \"removed user\": {\n                        \"summary\": JWT_ERROR_USER_REMOVED,\n                        \"value\": {\"detail\": JWT_ERROR_USER_REMOVED},\n                    },\n                }\n            }\n        },\n    }\n}\n\nACCESS_TOKEN_RESPONSES: dict[int | str, dict[str, Any]] = {\n    400: {\n        \"description\": \"Invalid email or password\",\n        \"content\": {\"application/json\": {\"example\": {\"detail\": PASSWORD_INVALID}}},\n    },\n}\n\nREFRESH_TOKEN_RESPONSES: dict[int | str, dict[str, Any]] = {\n    400: {\n        \"description\": \"Refresh token expired or is already used\",\n        \"content\": {\n            \"application/json\": {\n                \"examples\": {\n                    \"refresh token expired\": {\n                        \"summary\": REFRESH_TOKEN_EXPIRED,\n                        \"value\": {\"detail\": REFRESH_TOKEN_EXPIRED},\n                    },\n                    \"refresh token already used\": {\n                        \"summary\": REFRESH_TOKEN_ALREADY_USED,\n                        \"value\": {\"detail\": REFRESH_TOKEN_ALREADY_USED},\n                    },\n                }\n            }\n        },\n    },\n    404: {\n        \"description\": \"Refresh token does not exist\",\n        \"content\": {\n            \"application/json\": {\"example\": {\"detail\": REFRESH_TOKEN_NOT_FOUND}}\n        },\n    },\n}\n"
  },
  {
    "path": "app/auth/dependencies.py",
    "content": "from typing import Annotated\n\nfrom fastapi import Depends, HTTPException, status\nfrom fastapi.security import OAuth2PasswordBearer\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth import api_messages\nfrom app.auth.jwt import verify_jwt_token\nfrom app.auth.models import User\nfrom app.core import database_session\n\noauth2_scheme = OAuth2PasswordBearer(tokenUrl=\"auth/access-token\")\n\n\nasync def get_current_user(\n    token: Annotated[str, Depends(oauth2_scheme)],\n    session: AsyncSession = Depends(database_session.new_async_session),\n) -> User:\n    token_payload = verify_jwt_token(token)\n\n    user = await session.scalar(select(User).where(User.user_id == token_payload.sub))\n\n    if user is None:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=api_messages.JWT_ERROR_USER_REMOVED,\n        )\n    return user\n"
  },
  {
    "path": "app/auth/jwt.py",
    "content": "import time\n\nimport jwt\nfrom fastapi import HTTPException, status\nfrom pydantic import BaseModel\n\nfrom app.core.config import get_settings\n\n\n# Payload follows RFC 7519\n# https://www.rfc-editor.org/rfc/rfc7519#section-4.1\nclass JWTTokenPayload(BaseModel):\n    iss: str\n    sub: str\n    exp: int\n    iat: int\n\n\nclass JWTToken(BaseModel):\n    payload: JWTTokenPayload\n    access_token: str\n\n\ndef create_jwt_token(user_id: str) -> JWTToken:\n    iat = int(time.time())\n    exp = iat + get_settings().security.jwt_access_token_expire_secs\n\n    token_payload = JWTTokenPayload(\n        iss=get_settings().security.jwt_issuer,\n        sub=user_id,\n        exp=exp,\n        iat=iat,\n    )\n\n    access_token = jwt.encode(\n        token_payload.model_dump(),\n        key=get_settings().security.jwt_secret_key.get_secret_value(),\n        algorithm=get_settings().security.jwt_algorithm,\n    )\n\n    return JWTToken(payload=token_payload, access_token=access_token)\n\n\ndef verify_jwt_token(token: str) -> JWTTokenPayload:\n    # Pay attention to verify_signature passed explicite, even if it is the default.\n    # Verification is based on expected payload fields like \"exp\", \"iat\" etc.\n    # so if you rename for example \"exp\" to \"my_custom_exp\", this is gonna break,\n    # jwt.ExpiredSignatureError will not be raised, that can potentialy\n    # be major security risk - not validating tokens at all.\n    # If unsure, jump into jwt.decode code, make sure tests are passing\n    # https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-hs256\n\n    try:\n        raw_payload = jwt.decode(\n            token,\n            get_settings().security.jwt_secret_key.get_secret_value(),\n            algorithms=[get_settings().security.jwt_algorithm],\n            options={\"verify_signature\": True},\n            issuer=get_settings().security.jwt_issuer,\n        )\n    except jwt.InvalidTokenError as e:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=f\"Token invalid: {e}\",\n        )\n\n    return JWTTokenPayload(**raw_payload)\n"
  },
  {
    "path": "app/auth/models.py",
    "content": "# SQL Alchemy models declaration.\n# https://docs.sqlalchemy.org/en/20/orm/quickstart.html#declare-models\n# mapped_column syntax from SQLAlchemy 2.0.\n\n# https://alembic.sqlalchemy.org/en/latest/tutorial.html\n# Note, it is used by alembic migrations logic, see `alembic/env.py`\n\n# Alembic shortcuts:\n# # create migration\n# alembic revision --autogenerate -m \"migration_name\"\n\n# # apply all migrations\n# alembic upgrade head\n\n\nimport uuid\nfrom datetime import datetime\n\nimport sqlalchemy as sa\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom app.core.models import Base\n\n\nclass User(Base):\n    __tablename__ = \"auth_user\"\n\n    user_id: Mapped[str] = mapped_column(\n        sa.String(36), primary_key=True, default=lambda _: str(uuid.uuid4())\n    )\n    email: Mapped[str] = mapped_column(\n        sa.String(256), nullable=False, unique=True, index=True\n    )\n    hashed_password: Mapped[str] = mapped_column(sa.String(128), nullable=False)\n    created_at: Mapped[datetime] = mapped_column(\n        sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False\n    )\n    updated_at: Mapped[datetime] = mapped_column(\n        sa.DateTime(timezone=True),\n        server_default=sa.func.now(),\n        onupdate=sa.func.now(),\n        nullable=False,\n    )\n\n    refresh_tokens: Mapped[list[\"RefreshToken\"]] = relationship(back_populates=\"user\")  # noqa: UP037\n\n\nclass RefreshToken(Base):\n    __tablename__ = \"auth_refresh_token\"\n\n    id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True)\n    refresh_token: Mapped[str] = mapped_column(\n        sa.String(512), nullable=False, unique=True, index=True\n    )\n    used: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False)\n    exp: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)\n\n    user_id: Mapped[str] = mapped_column(\n        sa.ForeignKey(\"auth_user.user_id\", ondelete=\"CASCADE\"),\n    )\n    user: Mapped[User] = relationship(back_populates=\"refresh_tokens\")\n"
  },
  {
    "path": "app/auth/password.py",
    "content": "import bcrypt\n\nfrom app.core.config import get_settings\n\n\ndef verify_password(plain_password: str, hashed_password: str) -> bool:\n    return bcrypt.checkpw(\n        plain_password.encode(\"utf-8\"), hashed_password.encode(\"utf-8\")\n    )\n\n\ndef get_password_hash(password: str) -> str:\n    return bcrypt.hashpw(\n        password.encode(),\n        bcrypt.gensalt(get_settings().security.password_bcrypt_rounds),\n    ).decode()\n\n\nDUMMY_PASSWORD = get_password_hash(\"\")\n"
  },
  {
    "path": "app/auth/responses.py",
    "content": "from pydantic import BaseModel, ConfigDict, EmailStr\n\n\nclass BaseResponse(BaseModel):\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass AccessTokenResponse(BaseResponse):\n    token_type: str = \"Bearer\"\n    access_token: str\n    expires_at: int\n    refresh_token: str\n    refresh_token_expires_at: int\n\n\nclass UserResponse(BaseResponse):\n    user_id: str\n    email: EmailStr\n"
  },
  {
    "path": "app/auth/schemas.py",
    "content": "from pydantic import BaseModel, ConfigDict, EmailStr\n\n\nclass RefreshTokenRequest(BaseModel):\n    refresh_token: str\n\n\nclass UserUpdatePasswordRequest(BaseModel):\n    password: str\n\n\nclass UserCreateRequest(BaseModel):\n    email: EmailStr\n    password: str\n\n\nclass AccessTokenResponse(BaseModel):\n    token_type: str = \"Bearer\"\n    access_token: str\n    expires_at: int\n    refresh_token: str\n    refresh_token_expires_at: int\n\n    model_config = ConfigDict(from_attributes=True)\n\n\nclass UserResponse(BaseModel):\n    user_id: str\n    email: EmailStr\n\n    model_config = ConfigDict(from_attributes=True)\n"
  },
  {
    "path": "app/auth/tests/__init__.py",
    "content": ""
  },
  {
    "path": "app/auth/tests/test_jwt.py",
    "content": "import time\n\nimport pytest\nfrom fastapi import HTTPException\nfrom freezegun import freeze_time\nfrom pydantic import SecretStr\n\nfrom app.auth import jwt\nfrom app.core.config import get_settings\n\n\ndef test_jwt_access_token_can_be_decoded_back_into_user_id() -> None:\n    user_id = \"test_user_id\"\n    token = jwt.create_jwt_token(user_id)\n\n    payload = jwt.verify_jwt_token(token=token.access_token)\n    assert payload.sub == user_id\n\n\n@freeze_time(\"2024-01-01\")\ndef test_jwt_payload_is_correct() -> None:\n    user_id = \"test_user_id\"\n    token = jwt.create_jwt_token(user_id)\n\n    assert token.payload.iat == int(time.time())\n    assert token.payload.sub == user_id\n    assert token.payload.iss == get_settings().security.jwt_issuer\n    assert (\n        token.payload.exp\n        == int(time.time()) + get_settings().security.jwt_access_token_expire_secs\n    )\n\n\ndef test_jwt_error_after_exp_time() -> None:\n    user_id = \"test_user_id\"\n    with freeze_time(\"2024-01-01\"):\n        token = jwt.create_jwt_token(user_id)\n    with freeze_time(\"2024-02-01\"):\n        with pytest.raises(HTTPException) as e:\n            jwt.verify_jwt_token(token=token.access_token)\n\n        assert e.value.detail == \"Token invalid: Signature has expired\"\n\n\ndef test_jwt_error_before_iat_time() -> None:\n    user_id = \"test_user_id\"\n    with freeze_time(\"2024-01-01\"):\n        token = jwt.create_jwt_token(user_id)\n    with freeze_time(\"2023-12-01\"):\n        with pytest.raises(HTTPException) as e:\n            jwt.verify_jwt_token(token=token.access_token)\n\n        assert e.value.detail == \"Token invalid: The token is not yet valid (iat)\"\n\n\ndef test_jwt_error_with_invalid_token() -> None:\n    with pytest.raises(HTTPException) as e:\n        jwt.verify_jwt_token(token=\"invalid!\")\n\n    assert e.value.detail == \"Token invalid: Not enough segments\"\n\n\ndef test_jwt_error_with_invalid_issuer() -> None:\n    user_id = \"test_user_id\"\n    token = jwt.create_jwt_token(user_id)\n\n    get_settings().security.jwt_issuer = \"another_issuer\"\n\n    with pytest.raises(HTTPException) as e:\n        jwt.verify_jwt_token(token=token.access_token)\n\n    assert e.value.detail == \"Token invalid: Invalid issuer\"\n\n\ndef test_jwt_error_with_invalid_secret_key() -> None:\n    user_id = \"test_user_id\"\n    token = jwt.create_jwt_token(user_id)\n\n    get_settings().security.jwt_secret_key = SecretStr(\"x\" * 32)\n\n    with pytest.raises(HTTPException) as e:\n        jwt.verify_jwt_token(token=token.access_token)\n\n    assert e.value.detail == \"Token invalid: Signature verification failed\"\n"
  },
  {
    "path": "app/auth/tests/test_password.py",
    "content": "from app.auth.password import get_password_hash, verify_password\n\n\ndef test_hashed_password_is_verified() -> None:\n    pwd_hash = get_password_hash(\"my_password\")\n    assert verify_password(\"my_password\", pwd_hash)\n\n\ndef test_invalid_password_is_not_verified() -> None:\n    pwd_hash = get_password_hash(\"my_password\")\n    assert not verify_password(\"my_password_invalid\", pwd_hash)\n"
  },
  {
    "path": "app/auth/tests/test_view_delete_current_user.py",
    "content": "from fastapi import status\nfrom httpx import AsyncClient\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth.models import User\nfrom app.main import app\n\n\nasync def test_delete_current_user_status_code(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n) -> None:\n    response = await client.delete(\n        app.url_path_for(\"delete_current_user\"),\n        headers=default_user_headers,\n    )\n\n    assert response.status_code == status.HTTP_204_NO_CONTENT\n\n\nasync def test_delete_current_user_is_deleted_in_db(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    await client.delete(\n        app.url_path_for(\"delete_current_user\"),\n        headers=default_user_headers,\n    )\n\n    user = await session.scalar(\n        select(User).where(User.user_id == default_user.user_id)\n    )\n    assert user is None\n"
  },
  {
    "path": "app/auth/tests/test_view_login_access_token.py",
    "content": "import time\n\nfrom fastapi import status\nfrom freezegun import freeze_time\nfrom httpx import AsyncClient\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth import api_messages\nfrom app.auth.jwt import verify_jwt_token\nfrom app.auth.models import RefreshToken, User\nfrom app.core.config import get_settings\nfrom app.main import app\nfrom app.tests.auth import TESTS_USER_PASSWORD\n\n\nasync def test_login_access_token_has_response_status_code(\n    client: AsyncClient,\n    default_user: User,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": TESTS_USER_PASSWORD,\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n    assert response.status_code == status.HTTP_200_OK, response.text\n\n\nasync def test_login_access_token_jwt_has_valid_token_type(\n    client: AsyncClient,\n    default_user: User,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": TESTS_USER_PASSWORD,\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n    assert response.status_code == status.HTTP_200_OK, response.text\n    token = response.json()\n    assert token[\"token_type\"] == \"Bearer\"\n\n\n@freeze_time(\"2023-01-01\")\nasync def test_login_access_token_jwt_has_valid_expire_time(\n    client: AsyncClient,\n    default_user: User,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": TESTS_USER_PASSWORD,\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n    assert response.status_code == status.HTTP_200_OK, response.text\n    token = response.json()\n    current_timestamp = int(time.time())\n    assert (\n        token[\"expires_at\"]\n        == current_timestamp + get_settings().security.jwt_access_token_expire_secs\n    )\n\n\n@freeze_time(\"2023-01-01\")\nasync def test_login_access_token_returns_valid_jwt_access_token(\n    client: AsyncClient,\n    default_user: User,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": TESTS_USER_PASSWORD,\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n    assert response.status_code == status.HTTP_200_OK, response.text\n\n    now = int(time.time())\n    token = response.json()\n    token_payload = verify_jwt_token(token[\"access_token\"])\n\n    assert token_payload.sub == default_user.user_id\n    assert token_payload.iat == now\n    assert token_payload.exp == token[\"expires_at\"]\n\n\nasync def test_login_access_token_refresh_token_has_valid_expire_time(\n    client: AsyncClient,\n    default_user: User,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": TESTS_USER_PASSWORD,\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n    assert response.status_code == status.HTTP_200_OK, response.text\n\n    token = response.json()\n    current_time = int(time.time())\n    assert (\n        token[\"refresh_token_expires_at\"]\n        == current_time + get_settings().security.jwt_refresh_token_expire_secs\n    )\n\n\nasync def test_login_access_token_refresh_token_exists_in_db(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": TESTS_USER_PASSWORD,\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n    assert response.status_code == status.HTTP_200_OK, response.text\n\n    token = response.json()\n\n    token_db_count = await session.scalar(\n        select(func.count()).where(RefreshToken.refresh_token == token[\"refresh_token\"])\n    )\n    assert token_db_count == 1\n\n\nasync def test_login_access_token_refresh_token_in_db_has_valid_fields(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": TESTS_USER_PASSWORD,\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n    assert response.status_code == status.HTTP_200_OK, response.text\n\n    token = response.json()\n    result = await session.scalars(\n        select(RefreshToken).where(RefreshToken.refresh_token == token[\"refresh_token\"])\n    )\n    refresh_token = result.one()\n\n    assert refresh_token.user_id == default_user.user_id\n    assert refresh_token.exp == token[\"refresh_token_expires_at\"]\n    assert not refresh_token.used\n\n\nasync def test_auth_access_token_fail_for_not_existing_user_with_message(\n    client: AsyncClient,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": \"non-existing\",\n            \"password\": \"bla\",\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n\n    assert response.status_code == status.HTTP_400_BAD_REQUEST, response.text\n    assert response.json() == {\"detail\": api_messages.PASSWORD_INVALID}\n\n\nasync def test_auth_access_token_fail_for_invalid_password_with_message(\n    client: AsyncClient,\n    default_user: User,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"login_access_token\"),\n        data={\n            \"username\": default_user.email,\n            \"password\": \"invalid\",\n        },\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n    )\n\n    assert response.status_code == status.HTTP_400_BAD_REQUEST, response.text\n    assert response.json() == {\"detail\": api_messages.PASSWORD_INVALID}\n"
  },
  {
    "path": "app/auth/tests/test_view_read_current_user.py",
    "content": "from fastapi import status\nfrom freezegun import freeze_time\nfrom httpx import AsyncClient\nfrom sqlalchemy import delete\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth import api_messages\nfrom app.auth.jwt import create_jwt_token\nfrom app.auth.models import User\nfrom app.main import app\n\n\nasync def test_read_current_user_status_code(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n    default_user: User,\n) -> None:\n    response = await client.get(\n        app.url_path_for(\"read_current_user\"),\n        headers=default_user_headers,\n    )\n\n    assert response.status_code == status.HTTP_200_OK\n\n\nasync def test_read_current_user_response(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n    default_user: User,\n) -> None:\n    response = await client.get(\n        app.url_path_for(\"read_current_user\"),\n        headers=default_user_headers,\n    )\n\n    assert response.json() == {\n        \"user_id\": default_user.user_id,\n        \"email\": default_user.email,\n    }\n\n\nasync def test_api_raise_401_on_jwt_decode_errors(\n    client: AsyncClient,\n) -> None:\n    response = await client.get(\n        app.url_path_for(\"read_current_user\"),\n        headers={\"Authorization\": \"Bearer garbage-invalid-jwt\"},\n    )\n\n    assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text\n    assert response.json() == {\"detail\": \"Token invalid: Not enough segments\"}\n\n\nasync def test_api_raise_401_on_jwt_expired_token(\n    client: AsyncClient,\n    default_user: User,\n) -> None:\n    with freeze_time(\"2023-01-01\"):\n        jwt = create_jwt_token(default_user.user_id)\n    with freeze_time(\"2023-02-01\"):\n        response = await client.get(\n            app.url_path_for(\"read_current_user\"),\n            headers={\"Authorization\": f\"Bearer {jwt.access_token}\"},\n        )\n\n        assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text\n        assert response.json() == {\"detail\": \"Token invalid: Signature has expired\"}\n\n\nasync def test_api_raise_401_on_jwt_user_deleted(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    await session.execute(delete(User).where(User.user_id == default_user.user_id))\n    await session.commit()\n\n    response = await client.get(\n        app.url_path_for(\"read_current_user\"),\n        headers=default_user_headers,\n    )\n    assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text\n    assert response.json() == {\"detail\": api_messages.JWT_ERROR_USER_REMOVED}\n"
  },
  {
    "path": "app/auth/tests/test_view_refresh_token.py",
    "content": "import time\n\nfrom fastapi import status\nfrom freezegun import freeze_time\nfrom httpx import AsyncClient\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth import api_messages\nfrom app.auth.jwt import verify_jwt_token\nfrom app.auth.models import RefreshToken, User\nfrom app.core.config import get_settings\nfrom app.main import app\n\n\nasync def test_refresh_token_fails_with_message_when_token_does_not_exist(\n    client: AsyncClient,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    assert response.status_code == status.HTTP_404_NOT_FOUND\n    assert response.json() == {\"detail\": api_messages.REFRESH_TOKEN_NOT_FOUND}\n\n\nasync def test_refresh_token_fails_with_message_when_token_is_expired(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) - 1,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    assert response.status_code == status.HTTP_400_BAD_REQUEST\n    assert response.json() == {\"detail\": api_messages.REFRESH_TOKEN_EXPIRED}\n\n\nasync def test_refresh_token_fails_with_message_when_token_is_used(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=True,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    assert response.status_code == status.HTTP_400_BAD_REQUEST\n    assert response.json() == {\"detail\": api_messages.REFRESH_TOKEN_ALREADY_USED}\n\n\nasync def test_refresh_token_success_response_status_code(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=False,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    assert response.status_code == status.HTTP_200_OK\n\n\nasync def test_refresh_token_success_old_token_is_used(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=False,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    used_test_refresh_token = await session.scalar(\n        select(RefreshToken).where(RefreshToken.refresh_token == \"blaxx\")\n    )\n    assert used_test_refresh_token is not None\n    assert used_test_refresh_token.used\n\n\nasync def test_refresh_token_success_jwt_has_valid_token_type(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=False,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    token = response.json()\n    assert token[\"token_type\"] == \"Bearer\"\n\n\n@freeze_time(\"2023-01-01\")\nasync def test_refresh_token_success_jwt_has_valid_expire_time(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=False,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    token = response.json()\n    current_timestamp = int(time.time())\n    assert (\n        token[\"expires_at\"]\n        == current_timestamp + get_settings().security.jwt_access_token_expire_secs\n    )\n\n\n@freeze_time(\"2023-01-01\")\nasync def test_refresh_token_success_jwt_has_valid_access_token(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=False,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    now = int(time.time())\n    token = response.json()\n    token_payload = verify_jwt_token(token[\"access_token\"])\n\n    assert token_payload.sub == default_user.user_id\n    assert token_payload.iat == now\n    assert token_payload.exp == token[\"expires_at\"]\n\n\n@freeze_time(\"2023-01-01\")\nasync def test_refresh_token_success_refresh_token_has_valid_expire_time(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=False,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    token = response.json()\n    current_time = int(time.time())\n    assert (\n        token[\"refresh_token_expires_at\"]\n        == current_time + get_settings().security.jwt_refresh_token_expire_secs\n    )\n\n\nasync def test_refresh_token_success_new_refresh_token_is_in_db(\n    client: AsyncClient,\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    test_refresh_token = RefreshToken(\n        user_id=default_user.user_id,\n        refresh_token=\"blaxx\",\n        exp=int(time.time()) + 1000,\n        used=False,\n    )\n    session.add(test_refresh_token)\n    await session.commit()\n\n    response = await client.post(\n        app.url_path_for(\"refresh_token\"),\n        json={\n            \"refresh_token\": \"blaxx\",\n        },\n    )\n\n    token = response.json()\n    token_db_count = await session.scalar(\n        select(func.count()).where(RefreshToken.refresh_token == token[\"refresh_token\"])\n    )\n    assert token_db_count == 1\n"
  },
  {
    "path": "app/auth/tests/test_view_register_new_user.py",
    "content": "from fastapi import status\nfrom httpx import AsyncClient\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth import api_messages\nfrom app.auth.models import User\nfrom app.main import app\nfrom app.tests.factories import UserFactory\n\n\nasync def test_register_new_user_status_code(\n    client: AsyncClient,\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"register_new_user\"),\n        json={\n            \"email\": \"test@email.com\",\n            \"password\": \"testtesttest\",\n        },\n    )\n\n    assert response.status_code == status.HTTP_201_CREATED\n\n\nasync def test_register_new_user_creates_record_in_db(\n    client: AsyncClient,\n    session: AsyncSession,\n) -> None:\n    await client.post(\n        app.url_path_for(\"register_new_user\"),\n        json={\n            \"email\": \"test@email.com\",\n            \"password\": \"testtesttest\",\n        },\n    )\n\n    user_count = await session.scalar(\n        select(func.count()).where(User.email == \"test@email.com\")\n    )\n    assert user_count == 1\n\n\nasync def test_register_new_user_cannot_create_already_created_user(\n    client: AsyncClient,\n    session: AsyncSession,\n) -> None:\n    existing_user = await UserFactory.create_async()\n\n    response = await client.post(\n        app.url_path_for(\"register_new_user\"),\n        json={\n            \"email\": existing_user.email,\n            \"password\": \"testtesttest\",\n        },\n    )\n\n    assert response.status_code == status.HTTP_400_BAD_REQUEST\n    assert response.json() == {\"detail\": api_messages.EMAIL_ADDRESS_ALREADY_USED}\n"
  },
  {
    "path": "app/auth/tests/test_view_reset_current_user_password.py",
    "content": "from fastapi import status\nfrom httpx import AsyncClient\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth.models import User\nfrom app.auth.password import verify_password\nfrom app.main import app\n\n\nasync def test_reset_current_user_password_status_code(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n) -> None:\n    response = await client.post(\n        app.url_path_for(\"reset_current_user_password\"),\n        headers=default_user_headers,\n        json={\"password\": \"test_pwd\"},\n    )\n\n    assert response.status_code == status.HTTP_204_NO_CONTENT\n\n\nasync def test_reset_current_user_password_is_changed_in_db(\n    client: AsyncClient,\n    default_user_headers: dict[str, str],\n    default_user: User,\n    session: AsyncSession,\n) -> None:\n    await client.post(\n        app.url_path_for(\"reset_current_user_password\"),\n        headers=default_user_headers,\n        json={\"password\": \"test_pwd\"},\n    )\n\n    user = await session.scalar(\n        select(User).where(User.user_id == default_user.user_id)\n    )\n    assert user is not None\n    assert verify_password(\"test_pwd\", user.hashed_password)\n"
  },
  {
    "path": "app/auth/views.py",
    "content": "import secrets\nimport time\n\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom fastapi.security import OAuth2PasswordRequestForm\nfrom sqlalchemy import delete, select\nfrom sqlalchemy.exc import IntegrityError\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.auth import api_messages, dependencies\nfrom app.auth.jwt import create_jwt_token\nfrom app.auth.models import RefreshToken, User\nfrom app.auth.password import (\n    DUMMY_PASSWORD,\n    get_password_hash,\n    verify_password,\n)\nfrom app.auth.schemas import (\n    AccessTokenResponse,\n    RefreshTokenRequest,\n    UserCreateRequest,\n    UserResponse,\n    UserUpdatePasswordRequest,\n)\nfrom app.core.config import get_settings\nfrom app.core.database_session import new_async_session\n\nrouter = APIRouter(responses=api_messages.UNAUTHORIZED_RESPONSES)\n\n\n@router.get(\"/me\", response_model=UserResponse, description=\"Get current user\")\nasync def read_current_user(\n    current_user: User = Depends(dependencies.get_current_user),\n) -> User:\n    return current_user\n\n\n@router.delete(\n    \"/me\",\n    status_code=status.HTTP_204_NO_CONTENT,\n    description=\"Delete current user\",\n)\nasync def delete_current_user(\n    current_user: User = Depends(dependencies.get_current_user),\n    session: AsyncSession = Depends(new_async_session),\n) -> None:\n    await session.execute(delete(User).where(User.user_id == current_user.user_id))\n    await session.commit()\n\n\n@router.post(\n    \"/reset-password\",\n    status_code=status.HTTP_204_NO_CONTENT,\n    description=\"Update current user password\",\n)\nasync def reset_current_user_password(\n    user_update_password: UserUpdatePasswordRequest,\n    session: AsyncSession = Depends(new_async_session),\n    current_user: User = Depends(dependencies.get_current_user),\n) -> None:\n    current_user.hashed_password = get_password_hash(user_update_password.password)\n    session.add(current_user)\n    await session.commit()\n\n\n@router.post(\n    \"/access-token\",\n    response_model=AccessTokenResponse,\n    responses=api_messages.ACCESS_TOKEN_RESPONSES,\n    description=\"OAuth2 compatible token, get an access token for future requests using username and password\",\n)\nasync def login_access_token(\n    session: AsyncSession = Depends(new_async_session),\n    form_data: OAuth2PasswordRequestForm = Depends(),\n) -> AccessTokenResponse:\n    user = await session.scalar(select(User).where(User.email == form_data.username))\n\n    if user is None:\n        # this is naive method to not return early\n        verify_password(form_data.password, DUMMY_PASSWORD)\n\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=api_messages.PASSWORD_INVALID,\n        )\n\n    if not verify_password(form_data.password, user.hashed_password):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=api_messages.PASSWORD_INVALID,\n        )\n\n    jwt_token = create_jwt_token(user_id=user.user_id)\n\n    refresh_token = RefreshToken(\n        user_id=user.user_id,\n        refresh_token=secrets.token_urlsafe(32),\n        exp=int(time.time() + get_settings().security.jwt_refresh_token_expire_secs),\n    )\n    session.add(refresh_token)\n    await session.commit()\n\n    return AccessTokenResponse(\n        access_token=jwt_token.access_token,\n        expires_at=jwt_token.payload.exp,\n        refresh_token=refresh_token.refresh_token,\n        refresh_token_expires_at=refresh_token.exp,\n    )\n\n\n@router.post(\n    \"/refresh-token\",\n    response_model=AccessTokenResponse,\n    responses=api_messages.REFRESH_TOKEN_RESPONSES,\n    description=\"OAuth2 compatible token, get an access token for future requests using refresh token\",\n)\nasync def refresh_token(\n    data: RefreshTokenRequest,\n    session: AsyncSession = Depends(new_async_session),\n) -> AccessTokenResponse:\n    token = await session.scalar(\n        select(RefreshToken)\n        .where(RefreshToken.refresh_token == data.refresh_token)\n        .with_for_update(skip_locked=True)\n    )\n\n    if token is None:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=api_messages.REFRESH_TOKEN_NOT_FOUND,\n        )\n    elif time.time() > token.exp:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=api_messages.REFRESH_TOKEN_EXPIRED,\n        )\n    elif token.used:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=api_messages.REFRESH_TOKEN_ALREADY_USED,\n        )\n\n    token.used = True\n    session.add(token)\n\n    jwt_token = create_jwt_token(user_id=token.user_id)\n\n    refresh_token = RefreshToken(\n        user_id=token.user_id,\n        refresh_token=secrets.token_urlsafe(32),\n        exp=int(time.time() + get_settings().security.jwt_refresh_token_expire_secs),\n    )\n    session.add(refresh_token)\n    await session.commit()\n\n    return AccessTokenResponse(\n        access_token=jwt_token.access_token,\n        expires_at=jwt_token.payload.exp,\n        refresh_token=refresh_token.refresh_token,\n        refresh_token_expires_at=refresh_token.exp,\n    )\n\n\n@router.post(\n    \"/register\",\n    response_model=UserResponse,\n    description=\"Create new user\",\n    status_code=status.HTTP_201_CREATED,\n)\nasync def register_new_user(\n    new_user: UserCreateRequest,\n    session: AsyncSession = Depends(new_async_session),\n) -> User:\n    user = await session.scalar(select(User).where(User.email == new_user.email))\n    if user is not None:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=api_messages.EMAIL_ADDRESS_ALREADY_USED,\n        )\n\n    user = User(\n        email=new_user.email,\n        hashed_password=get_password_hash(new_user.password),\n    )\n    session.add(user)\n\n    try:\n        await session.commit()\n    except IntegrityError:  # pragma: no cover\n        await session.rollback()\n\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=api_messages.EMAIL_ADDRESS_ALREADY_USED,\n        )\n\n    return user\n"
  },
  {
    "path": "app/conftest.py",
    "content": "import asyncio\nimport os\nfrom collections.abc import AsyncGenerator\n\nimport alembic.command\nimport alembic.config\nimport pytest\nimport pytest_asyncio\nimport sqlalchemy\nfrom httpx import ASGITransport, AsyncClient\nfrom polyfactory.factories.sqlalchemy_factory import (\n    SQLAASyncPersistence,\n    SQLAlchemyFactory,\n)\nfrom sqlalchemy.ext.asyncio import (\n    AsyncSession,\n    async_sessionmaker,\n)\n\nfrom app.auth.jwt import create_jwt_token\nfrom app.auth.models import User\nfrom app.core import database_session\nfrom app.core.config import PROJECT_DIR, get_settings\nfrom app.core.database_session import new_async_session\nfrom app.main import app as fastapi_app\nfrom app.tests.factories import UserFactory\n\n\n@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\", autouse=True)\nasync def fixture_setup_new_test_database() -> AsyncGenerator[None]:\n    worker_name = os.getenv(\"PYTEST_XDIST_WORKER\", \"gw0\")\n    test_db_name = f\"test_db_{worker_name}\"\n\n    # create new test db using connection to current database\n    conn = await database_session._ASYNC_ENGINE.connect()\n    await conn.execution_options(isolation_level=\"AUTOCOMMIT\")\n    await conn.execute(sqlalchemy.text(f\"DROP DATABASE IF EXISTS {test_db_name}\"))\n    await conn.execute(sqlalchemy.text(f\"CREATE DATABASE {test_db_name}\"))\n    await conn.close()\n\n    # dispose the original engine before switching to test database\n    await database_session._ASYNC_ENGINE.dispose()\n\n    session_mpatch = pytest.MonkeyPatch()\n    session_mpatch.setenv(\"DATABASE__DB\", test_db_name)\n    session_mpatch.setenv(\"SECURITY__PASSWORD_BCRYPT_ROUNDS\", \"4\")\n\n    # force settings to use now monkeypatched environments\n    get_settings.cache_clear()\n\n    # monkeypatch test database engine\n    engine = database_session.new_async_engine(get_settings().sqlalchemy_database_uri)\n\n    session_mpatch.setattr(\n        database_session,\n        \"_ASYNC_ENGINE\",\n        engine,\n    )\n    session_mpatch.setattr(\n        database_session,\n        \"_ASYNC_SESSIONMAKER\",\n        async_sessionmaker(engine, expire_on_commit=False),\n    )\n\n    def alembic_upgrade() -> None:\n        # synchronous function to run alembic upgrade\n        alembic_config = alembic.config.Config(PROJECT_DIR / \"alembic.ini\")\n        alembic.command.upgrade(alembic_config, \"head\")\n\n    loop = asyncio.get_running_loop()\n    await loop.run_in_executor(None, alembic_upgrade)\n\n    yield\n\n    # cleanup: dispose the test engine\n    await engine.dispose()\n\n\n@pytest_asyncio.fixture(scope=\"function\", loop_scope=\"session\", autouse=True)\nasync def fixture_clean_get_settings_between_tests() -> AsyncGenerator[None]:\n    yield\n\n    get_settings.cache_clear()\n\n\n@pytest_asyncio.fixture(name=\"session\", loop_scope=\"session\", scope=\"function\")\nasync def fixture_session_with_rollback(\n    monkeypatch: pytest.MonkeyPatch,\n) -> AsyncGenerator[AsyncSession]:\n    # we want to monkeypatch new_async_session with one bound to session\n    # that we will always rollback on function scope\n\n    connection = await database_session._ASYNC_ENGINE.connect()\n    transaction = await connection.begin()\n\n    session = AsyncSession(bind=connection, expire_on_commit=False)\n\n    monkeypatch.setattr(\n        database_session,\n        \"new_script_async_session\",\n        lambda: session,\n    )\n\n    fastapi_app.dependency_overrides[new_async_session] = lambda: session\n\n    # now some work around SQLAlchemyFactory to actually use our session\n    # refer to https://polyfactory.litestar.dev/latest/usage/configuration.html\n    persistence_handler = SQLAASyncPersistence(session=session)  # type: ignore\n    setattr(SQLAlchemyFactory, \"__async_persistence__\", persistence_handler)\n\n    yield session\n\n    setattr(SQLAlchemyFactory, \"__async_persistence__\", None)\n\n    fastapi_app.dependency_overrides.pop(new_async_session, None)\n\n    await session.close()\n    await transaction.rollback()\n    await connection.close()\n\n\n@pytest_asyncio.fixture(name=\"client\", loop_scope=\"session\", scope=\"function\")\nasync def fixture_client(session: AsyncSession) -> AsyncGenerator[AsyncClient]:\n    transport = ASGITransport(app=fastapi_app)\n    async with AsyncClient(transport=transport, base_url=\"http://test\") as aclient:\n        aclient.headers.update({\"Host\": \"localhost\"})\n        yield aclient\n\n\n@pytest_asyncio.fixture(name=\"default_user\", loop_scope=\"session\", scope=\"function\")\nasync def fixture_default_user(session: AsyncSession) -> AsyncGenerator[User]:\n    yield await UserFactory.create_async()\n\n\n@pytest_asyncio.fixture(\n    name=\"default_user_headers\", loop_scope=\"session\", scope=\"function\"\n)\nasync def fixture_default_user_headers(default_user: User) -> dict[str, str]:\n    access_token = create_jwt_token(user_id=default_user.user_id).access_token\n    return {\"Authorization\": f\"Bearer {access_token}\"}\n"
  },
  {
    "path": "app/core/__init__.py",
    "content": ""
  },
  {
    "path": "app/core/config.py",
    "content": "# File with environment variables and general configuration logic.\n# Env variables are combined in nested groups like \"Security\", \"Database\" etc.\n# So environment variable (case-insensitive) for jwt_secret_key will be \"security__jwt_secret_key\"\n#\n# Pydantic priority ordering:\n#\n# 1. (Most important, will overwrite everything) - environment variables\n# 2. `.env` file in root folder of project\n# 3. Default values\n#\n# \"sqlalchemy_database_uri\" is computed field that will create valid database URL\n#\n# See https://pydantic-docs.helpmanual.io/usage/settings/\n# Note, complex types like lists are read as json-encoded strings.\n\n\nimport logging.config\nfrom functools import lru_cache\nfrom pathlib import Path\n\nfrom pydantic import AnyHttpUrl, BaseModel, Field, SecretStr, computed_field\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\nfrom sqlalchemy.engine.url import URL\n\nPROJECT_DIR = Path(__file__).parent.parent.parent\n\n\nclass Security(BaseModel):\n    jwt_issuer: str = \"my-app\"\n    jwt_secret_key: SecretStr = SecretStr(\n        \"change-me-to-a-strong-secret-key-at-least-32-chars-long\"\n    )\n    jwt_access_token_expire_secs: int = Field(default=15 * 60, gt=10)  # 15min\n    jwt_refresh_token_expire_secs: int = Field(default=28 * 24 * 3600, gt=60)  # 28d\n    jwt_algorithm: str = \"HS256\"\n\n    password_bcrypt_rounds: int = 12\n    allowed_hosts: list[str] = [\"localhost\", \"127.0.0.1\", \"0.0.0.0\"]\n    backend_cors_origins: list[AnyHttpUrl] = []\n\n\nclass Database(BaseModel):\n    hostname: str = \"postgres\"\n    username: str = \"postgres\"\n    password: SecretStr = SecretStr(\"passwd-change-me\")\n    port: int = 5432\n    db: str = \"postgres\"\n\n\nclass Prometheus(BaseModel):\n    enabled: bool = False\n    port: int = 9090\n    addr: str = \"0.0.0.0\"\n    stop_delay_secs: int = 0\n\n\nclass Settings(BaseSettings):\n    security: Security = Field(default_factory=Security)\n    database: Database = Field(default_factory=Database)\n    prometheus: Prometheus = Field(default_factory=Prometheus)\n\n    log_level: str = \"INFO\"\n\n    @computed_field  # type: ignore[prop-decorator]\n    @property\n    def sqlalchemy_database_uri(self) -> URL:\n        return URL.create(\n            drivername=\"postgresql+asyncpg\",\n            username=self.database.username,\n            password=self.database.password.get_secret_value(),\n            host=self.database.hostname,\n            port=self.database.port,\n            database=self.database.db,\n        )\n\n    model_config = SettingsConfigDict(\n        env_file=f\"{PROJECT_DIR}/.env\",\n        case_sensitive=False,\n        env_nested_delimiter=\"__\",\n    )\n\n\n@lru_cache(maxsize=1)\ndef get_settings() -> Settings:\n    return Settings()\n\n\ndef logging_config(log_level: str) -> None:\n    conf = {\n        \"version\": 1,\n        \"disable_existing_loggers\": False,\n        \"formatters\": {\n            \"verbose\": {\n                \"format\": \"{asctime} [{levelname}] {name}: {message}\",\n                \"style\": \"{\",\n            },\n        },\n        \"handlers\": {\n            \"stream\": {\n                \"class\": \"logging.StreamHandler\",\n                \"formatter\": \"verbose\",\n                \"level\": \"DEBUG\",\n            },\n        },\n        \"loggers\": {\n            \"\": {\n                \"level\": log_level,\n                \"handlers\": [\"stream\"],\n                \"propagate\": True,\n            },\n        },\n    }\n    logging.config.dictConfig(conf)\n\n\nlogging_config(log_level=get_settings().log_level)\n"
  },
  {
    "path": "app/core/database_session.py",
    "content": "# SQLAlchemy async engine and sessions tools\n#\n# https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html\n#\n# for pool size configuration:\n# https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.Pool\n\n\nfrom collections.abc import AsyncGenerator\nfrom contextlib import asynccontextmanager\n\nfrom sqlalchemy.engine.url import URL\nfrom sqlalchemy.ext.asyncio import (\n    AsyncEngine,\n    AsyncSession,\n    async_sessionmaker,\n    create_async_engine,\n)\n\nfrom app.core.config import get_settings\n\n\ndef new_async_engine(uri: URL) -> AsyncEngine:\n    return create_async_engine(\n        uri,\n        pool_pre_ping=True,\n        pool_size=5,\n        max_overflow=10,\n        pool_timeout=30.0,\n        pool_recycle=600,\n    )\n\n\n_ASYNC_ENGINE = new_async_engine(get_settings().sqlalchemy_database_uri)\n_ASYNC_SESSIONMAKER = async_sessionmaker(_ASYNC_ENGINE, expire_on_commit=False)\n\n\nasync def new_async_session() -> AsyncGenerator[AsyncSession]:  # pragma: no cover\n    session = _ASYNC_SESSIONMAKER()\n    try:\n        yield session\n    finally:\n        await session.close()\n\n\n@asynccontextmanager\nasync def new_script_async_session() -> AsyncGenerator[\n    AsyncSession\n]:  # pragma: no cover\n    # you can use this version inside scripts that run eg. as cronjobs outside of FastAPI context\n    # that you will run with asyncio.run()\n    # Global enginer and sessionmaker are created by global loop and cannot be shared across loops,\n    # so we need to create new ones here\n    _engine = create_async_engine(\n        get_settings().sqlalchemy_database_uri, pool_pre_ping=True\n    )\n    _async_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)\n\n    session = _async_sessionmaker()\n    try:\n        yield session\n    finally:\n        await session.close()\n        await _engine.dispose()\n"
  },
  {
    "path": "app/core/lifespan.py",
    "content": "import asyncio\nimport logging\nfrom collections.abc import AsyncGenerator\nfrom contextlib import asynccontextmanager\n\nimport prometheus_client\nfrom fastapi import FastAPI\n\nfrom app.core import database_session, metrics\nfrom app.core.config import get_settings\n\nlogger = logging.getLogger(__name__)\n\n\n@asynccontextmanager\nasync def lifespan(_: FastAPI) -> AsyncGenerator[None]:  # pragma: no cover\n    logger.info(\"starting application...\")\n    if get_settings().prometheus.enabled:\n        logger.info(\n            \"starting prometheus client server on interface %s port %d\",\n            get_settings().prometheus.addr,\n            get_settings().prometheus.port,\n        )\n\n        prometheus_client.start_http_server(\n            addr=get_settings().prometheus.addr,\n            port=get_settings().prometheus.port,\n        )\n        metrics.APP_STARTED.inc()\n\n    yield\n\n    logger.info(\"shutting down application...\")\n\n    await database_session._ASYNC_ENGINE.dispose()\n    logger.info(\"disposed database engine and closed connections...\")\n\n    if get_settings().prometheus.enabled:\n        logger.info(\n            \"stopping prometheus with delay of %d seconds...\",\n            get_settings().prometheus.stop_delay_secs,\n        )\n        metrics.APP_STOPPED.inc()\n        await asyncio.sleep(get_settings().prometheus.stop_delay_secs)\n\n    logger.info(\"bye! application shutdown completed\")\n"
  },
  {
    "path": "app/core/metrics.py",
    "content": "import prometheus_client\n\nNAMESPACE = \"org\"\nSUBSYSTEM = \"app\"\n\nAPP_STARTED = prometheus_client.Counter(\n    \"app_started_total\",\n    \"FastAPI application start count\",\n    labelnames=(),\n    namespace=NAMESPACE,\n    subsystem=SUBSYSTEM,\n)\n\nAPP_STOPPED = prometheus_client.Counter(\n    \"app_stopped\",\n    \"FastAPI application stop count\",\n    labelnames=(),\n    namespace=NAMESPACE,\n    subsystem=SUBSYSTEM,\n)\n"
  },
  {
    "path": "app/core/models.py",
    "content": "from sqlalchemy.orm import DeclarativeBase\n\n\nclass Base(DeclarativeBase):\n    pass\n"
  },
  {
    "path": "app/main.py",
    "content": "import logging\n\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.trustedhost import TrustedHostMiddleware\n\nfrom app.auth.views import router as auth_router\nfrom app.core import lifespan\nfrom app.core.config import get_settings\nfrom app.probe.views import router as probe_router\n\nlogger = logging.getLogger(__name__)\n\n\napp = FastAPI(\n    title=\"minimal fastapi postgres template\",\n    version=\"7.0.0\",\n    description=\"https://github.com/rafsaf/minimal-fastapi-postgres-template\",\n    openapi_url=\"/openapi.json\",\n    docs_url=\"/\",\n    lifespan=lifespan.lifespan,\n)\n\napp.include_router(auth_router, prefix=\"/auth\", tags=[\"auth\"])\napp.include_router(probe_router, prefix=\"/probe\", tags=[\"probe\"])\n\n# Sets all CORS enabled origins\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\n        str(origin).rstrip(\"/\")\n        for origin in get_settings().security.backend_cors_origins\n    ],\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n\n# Guards against HTTP Host Header attacks\napp.add_middleware(\n    TrustedHostMiddleware,\n    allowed_hosts=get_settings().security.allowed_hosts,\n)\n"
  },
  {
    "path": "app/probe/__init__.py",
    "content": ""
  },
  {
    "path": "app/probe/tests/__init__.py",
    "content": ""
  },
  {
    "path": "app/probe/tests/test_views.py",
    "content": "from httpx import AsyncClient, codes\n\nfrom app.main import app\n\n\nasync def test_live_probe(client: AsyncClient) -> None:\n    response = await client.get(app.url_path_for(\"live_probe\"))\n\n    assert response.status_code == codes.OK\n    assert response.text == '\"ok\"'\n\n\nasync def test_health_probe(client: AsyncClient) -> None:\n    response = await client.get(app.url_path_for(\"health_probe\"))\n\n    assert response.status_code == codes.OK\n    assert response.text == '\"app and database ok\"'\n"
  },
  {
    "path": "app/probe/views.py",
    "content": "import logging\nimport typing\n\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom app.core.database_session import new_async_session\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter()\n\n\n@router.get(\"/live\", response_model=str)\nasync def live_probe() -> typing.Literal[\"ok\"]:\n    return \"ok\"\n\n\n@router.get(\"/health\", response_model=str)\nasync def health_probe(\n    _: AsyncSession = Depends(new_async_session),\n) -> typing.Literal[\"app and database ok\"]:\n    return \"app and database ok\"\n"
  },
  {
    "path": "app/tests/auth.py",
    "content": "TESTS_USER_PASSWORD = \"geralt\"\n"
  },
  {
    "path": "app/tests/factories.py",
    "content": "import logging\nfrom typing import TypeVar\n\nfrom faker import Faker\nfrom polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory\nfrom polyfactory.fields import Use\n\nfrom app.auth.models import User\nfrom app.auth.password import get_password_hash\nfrom app.tests.auth import TESTS_USER_PASSWORD\n\nlogging.getLogger(\"factory\").setLevel(logging.ERROR)\n\n\nT = TypeVar(\"T\")\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass UserFactory(SQLAlchemyFactory[User]):\n    email = Use(Faker().email)\n    hashed_password = Use(lambda: get_password_hash(TESTS_USER_PASSWORD))\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  postgres_db:\n    restart: unless-stopped\n    image: postgres:18\n    volumes:\n      - postgres_db:/var/lib/postgresql\n    environment:\n      - POSTGRES_DB=${DATABASE__DB}\n      - POSTGRES_USER=${DATABASE__USERNAME}\n      - POSTGRES_PASSWORD=${DATABASE__PASSWORD}\n    env_file:\n      - .env\n    ports:\n      - \"${DATABASE__PORT}:5432\"\n\nvolumes:\n  postgres_db:\n"
  },
  {
    "path": "init.sh",
    "content": "#!/bin/bash\nset -e\n\necho \"Run migrations\"\nalembic upgrade head\n\n# Run whatever CMD was passed\nexec \"$@\""
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nauthors = [{ name = \"admin\", email = \"admin@example.com\" }]\ndependencies = [\n    \"alembic>=1.18.3\",\n    \"asyncpg>=0.31.0\",\n    \"bcrypt>=5.0.0\",\n    \"fastapi>=0.128.0\",\n    \"prometheus-client>=0.24.1\",\n    \"pydantic-settings>=2.12.0\",\n    \"pydantic[email]>=2.12.5\",\n    \"pyjwt>=2.11.0\",\n    \"python-multipart>=0.0.22\",\n    \"sqlalchemy[asyncio]>=2.0.46\",\n]\ndescription = \"FastAPI project generated using minimal-fastapi-postgres-template.\"\nname = \"app\"\nrequires-python = \">=3.14,<3.15\"\nversion = \"0.1.0-alpha\"\n\n[dependency-groups]\ndev = [\n    \"coverage>=7.13.2\",\n    \"freezegun>=1.5.5\",\n    \"greenlet>=3.3.1\",\n    \"httpx>=0.28.1\",\n    \"mypy>=1.19.1\",\n    \"polyfactory>=3.2.0\",\n    \"pre-commit>=4.5.1\",\n    \"pytest-asyncio>=1.3.0\",\n    \"pytest-cov>=7.0.0\",\n    \"pytest-xdist>=3.8.0\",\n    \"pytest>=9.0.2\",\n    \"ruff>=0.14.14\",\n    \"uvicorn[standard]>=0.40.0\",\n]\n\n[tool.uv]\npackage = false\n\n[build-system]\nbuild-backend = \"hatchling.build\"\nrequires = [\"hatchling\"]\n\n[tool.pytest.ini_options]\naddopts = \"-vv -n auto --cov --cov-report xml --cov-report term-missing --cov-fail-under=100\"\nasyncio_default_test_loop_scope = \"session\"\nasyncio_mode = \"auto\"\nfilterwarnings = [\"\"]\ntestpaths = [\"app\"]\n\n[tool.coverage.run]\nconcurrency = [\"greenlet\"]\nomit = [\"alembic/*\", \"app/tests/*\", \"conftest.py\", \"test_*.py\"]\nsource = [\"app\"]\n\n[tool.mypy]\nexclude = [\".venv\", \"alembic\"]\nfiles = \"app/**\"\npython_version = \"3.14\"\nstrict = true\n\n[tool.ruff]\ntarget-version = \"py314\"\n\n[tool.ruff.lint]\n# pycodestyle, pyflakes, isort, pylint, pyupgrade\nignore = [\"E501\"]\nselect = [\"E\", \"F\", \"I\", \"PL\", \"UP\", \"W\"]\n"
  }
]