docker-security-audit complete - added go lint and dependabot

This commit is contained in:
CarterPerez-dev 2026-01-02 11:24:04 -05:00
parent ad4b17966b
commit 1f241ccd0c
244 changed files with 15935 additions and 30333 deletions

92
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,92 @@
version: 2
updates:
# Python projects
- package-ecosystem: "pip"
directory: "/PROJECTS/api-rate-limiter"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
python-dependencies:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/PROJECTS/dns-lookup"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
python-dependencies:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/PROJECTS/keylogger"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
python-dependencies:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/PROJECTS/api-security-scanner/backend"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
python-dependencies:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/PROJECTS/encrypted-p2p-chat/backend"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
python-dependencies:
patterns:
- "*"
# npm projects
- package-ecosystem: "npm"
directory: "/PROJECTS/api-security-scanner/frontend"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
npm-dependencies:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/PROJECTS/encrypted-p2p-chat/frontend"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
npm-dependencies:
patterns:
- "*"
# Go projects
- package-ecosystem: "gomod"
directory: "/PROJECTS/docker-security-audit"
schedule:
interval: "weekly"
day: "friday"
time: "12:00"
groups:
go-dependencies:
patterns:
- "*"

View File

@ -10,40 +10,88 @@ on:
jobs:
lint:
name: Run Linters
name: Lint ${{ matrix.name }}
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: api-rate-limiter
type: python
path: PROJECTS/api-rate-limiter
- name: dns-lookup
type: python
path: PROJECTS/dns-lookup
- name: keylogger
type: python
path: PROJECTS/keylogger
- name: api-security-scanner
type: typescript
path: PROJECTS/api-security-scanner/frontend
- name: docker-security-audit
type: go
path: PROJECTS/docker-security-audit
defaults:
run:
working-directory: PROJECTS/backend
working-directory: ${{ matrix.path }}
steps:
- name: Checkout code
uses: actions/checkout@v4
# Python Setup
- name: Set up Python
if: matrix.type == 'python'
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Cache pip dependencies
if: matrix.type == 'python'
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('PROJECTS/backend/pyproject.toml') }}
key: ${{ runner.os }}-pip-${{ matrix.name }}-${{ hashFiles(format('{0}/pyproject.toml', matrix.path)) }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.name }}-
${{ runner.os }}-pip-
- name: Install dependencies
- name: Install Python dependencies
if: matrix.type == 'python'
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
# TypeScript Setup
- name: Setup Node.js
if: matrix.type == 'typescript'
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ matrix.path }}/package-lock.json
- name: Install TypeScript dependencies
if: matrix.type == 'typescript'
run: npm install
# Go Setup
- name: Setup Go
if: matrix.type == 'go'
uses: actions/setup-go@v5
with:
go-version: '1.21'
cache-dependency-path: ${{ matrix.path }}/go.sum
# Python Linting
- name: Run pylint
if: matrix.type == 'python'
id: pylint
run: |
echo "Running pylint..."
@ -58,6 +106,7 @@ jobs:
continue-on-error: true
- name: Run ruff
if: matrix.type == 'python'
id: ruff
run: |
echo "Running ruff check..."
@ -72,6 +121,7 @@ jobs:
continue-on-error: true
- name: Run mypy
if: matrix.type == 'python'
id: mypy
run: |
echo "Running mypy..."
@ -85,20 +135,67 @@ jobs:
cat mypy-output.txt
continue-on-error: true
- name: Create Lint Summary
id: create_summary
if: github.event_name == 'pull_request'
# TypeScript Linting
- name: Run ESLint
if: matrix.type == 'typescript'
id: eslint
run: |
echo "Running ESLint..."
if npm run lint:eslint > eslint-output.txt 2>&1; then
echo "ESLINT_PASSED=true" >> $GITHUB_ENV
echo "No ESLint errors found!"
else
echo "ESLINT_PASSED=false" >> $GITHUB_ENV
echo "ESLint found issues!"
fi
cat eslint-output.txt
continue-on-error: true
- name: Run TypeScript Check
if: matrix.type == 'typescript'
id: tsc
run: |
echo "Running TypeScript type checking..."
if npx tsc --noEmit > tsc-output.txt 2>&1; then
echo "TSC_PASSED=true" >> $GITHUB_ENV
echo "No TypeScript errors found!"
else
echo "TSC_PASSED=false" >> $GITHUB_ENV
echo "TypeScript found issues!"
fi
cat tsc-output.txt
continue-on-error: true
# Go Linting
- name: Run golangci-lint
if: matrix.type == 'go'
id: golangci
run: |
echo "Running golangci-lint..."
if golangci-lint run --out-format=colored-line-number > golangci-output.txt 2>&1; then
echo "GOLANGCI_PASSED=true" >> $GITHUB_ENV
echo "No golangci-lint errors found!"
else
echo "GOLANGCI_PASSED=false" >> $GITHUB_ENV
echo "golangci-lint found issues!"
fi
cat golangci-output.txt
continue-on-error: true
# Create Summary for Python
- name: Create Python Lint Summary
if: matrix.type == 'python' && github.event_name == 'pull_request'
run: |
{
echo '##Lint & Type Check Results'
echo "## Lint Results: ${{ matrix.name }}"
echo ''
# Pylint Status
if [[ "${{ env.PYLINT_PASSED }}" == "true" ]]; then
echo '###Pylint: **Passed**'
echo '### Pylint: **Passed**'
echo 'No pylint issues found.'
else
echo '###Pylint: **Issues Found**'
echo '### Pylint: **Issues Found**'
echo '<details><summary>View pylint output</summary>'
echo ''
echo '```'
@ -110,10 +207,10 @@ jobs:
# Ruff Status
if [[ "${{ env.RUFF_PASSED }}" == "true" ]]; then
echo '###Ruff: **Passed**'
echo '### Ruff: **Passed**'
echo 'No ruff issues found.'
else
echo '### ⚠️ Ruff: **Issues Found**'
echo '### Ruff: **Issues Found**'
echo '<details><summary>View ruff output</summary>'
echo ''
echo '```'
@ -144,16 +241,124 @@ jobs:
echo '### All checks passed!'
else
echo '---'
echo '### Review the issues above and consider fixing them.'
echo '### Review the issues above'
fi
echo ''
echo '<!-- lint-check-comment-marker -->'
} > ../lint-report.md
echo "<!-- lint-check-${{ matrix.name }}-marker -->"
} > lint-report.md
# Create Summary for TypeScript
- name: Create TypeScript Lint Summary
if: matrix.type == 'typescript' && github.event_name == 'pull_request'
run: |
{
echo "## Lint Results: ${{ matrix.name }}"
echo ''
# ESLint Status
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]]; then
echo '### ESLint: **Passed**'
echo 'No ESLint issues found.'
else
echo '### ESLint: **Issues Found**'
echo '<details><summary>View ESLint output</summary>'
echo ''
echo '```'
head -100 eslint-output.txt
echo '```'
echo '</details>'
fi
echo ''
# TypeScript Status
if [[ "${{ env.TSC_PASSED }}" == "true" ]]; then
echo '### TypeScript: **Passed**'
echo 'No type errors found.'
else
echo '### TypeScript: **Issues Found**'
echo '<details><summary>View TypeScript output</summary>'
echo ''
echo '```'
head -100 tsc-output.txt
echo '```'
echo '</details>'
fi
echo ''
# Overall Summary
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]] && [[ "${{ env.TSC_PASSED }}" == "true" ]]; then
echo '---'
echo '### All checks passed!'
else
echo '---'
echo '### Review the issues above'
fi
echo ''
echo "<!-- lint-check-${{ matrix.name }}-marker -->"
} > lint-report.md
# Create Summary for Go
- name: Create Go Lint Summary
if: matrix.type == 'go' && github.event_name == 'pull_request'
run: |
{
echo "## Lint Results: ${{ matrix.name }}"
echo ''
# golangci-lint Status
if [[ "${{ env.GOLANGCI_PASSED }}" == "true" ]]; then
echo '### golangci-lint: **Passed**'
echo 'No golangci-lint issues found.'
else
echo '### golangci-lint: **Issues Found**'
echo '<details><summary>View golangci-lint output</summary>'
echo ''
echo '```'
head -100 golangci-output.txt
echo '```'
echo '</details>'
fi
echo ''
# Overall Summary
if [[ "${{ env.GOLANGCI_PASSED }}" == "true" ]]; then
echo '---'
echo '### All checks passed!'
else
echo '---'
echo '### Review the issues above'
fi
echo ''
echo "<!-- lint-check-${{ matrix.name }}-marker -->"
} > lint-report.md
# Post PR Comment
- name: Post PR Comment
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
body-path: PROJECTS/api-security-scanner/lint-report.md
comment-marker: lint-check-comment-marker
body-path: ${{ matrix.path }}/lint-report.md
edit-mode: replace
comment-tag: lint-check-${{ matrix.name }}
# Exit with proper status
- name: Check lint status
run: |
if [[ "${{ matrix.type }}" == "python" ]]; then
if [[ "${{ env.PYLINT_PASSED }}" == "false" ]] || [[ "${{ env.RUFF_PASSED }}" == "false" ]] || [[ "${{ env.MYPY_PASSED }}" == "false" ]]; then
echo "Python lint checks failed"
exit 1
fi
elif [[ "${{ matrix.type }}" == "typescript" ]]; then
if [[ "${{ env.ESLINT_PASSED }}" == "false" ]] || [[ "${{ env.TSC_PASSED }}" == "false" ]]; then
echo "TypeScript lint checks failed"
exit 1
fi
elif [[ "${{ matrix.type }}" == "go" ]]; then
if [[ "${{ env.GOLANGCI_PASSED }}" == "false" ]]; then
echo "Go lint checks failed"
exit 1
fi
fi
echo "All lint checks passed"

View File

@ -1,86 +0,0 @@
# =============================================================================
# AngelaMos | 2025
# .env.example
# =============================================================================
# Copy this file to .env and update values for your environment
# =============================================================================
# =============================================================================
# HOST PORTS (change these to avoid conflicts between projects)
# =============================================================================
NGINX_HOST_PORT=8420
BACKEND_HOST_PORT=5420
FRONTEND_HOST_PORT=3420
POSTGRES_HOST_PORT=4420
REDIS_HOST_PORT=6420
# =============================================================================
# Application
# =============================================================================
APP_NAME=FullStack-Template
ENVIRONMENT=development
DEBUG=true
LOG_LEVEL=INFO
LOG_JSON_FORMAT=false
# =============================================================================
# Server (internal container settings)
# =============================================================================
HOST=0.0.0.0
PORT=8000
RELOAD=true
# =============================================================================
# PostgreSQL
# =============================================================================
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=app_db
POSTGRES_HOST=db
POSTGRES_CONTAINER_PORT=5432
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_CONTAINER_PORT}/${POSTGRES_DB}
DB_POOL_SIZE=20
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800
# =============================================================================
# Redis
# =============================================================================
REDIS_HOST=redis
REDIS_CONTAINER_PORT=6379
REDIS_PASSWORD=
REDIS_URL=redis://${REDIS_HOST}:${REDIS_CONTAINER_PORT}
# =============================================================================
# Security / JWT
# =============================================================================
SECRET_KEY=dev-only-change-this-in-production-minimum-32-characters-long
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# =============================================================================
# CORS (must match HOST PORTS above!)
# =============================================================================
# Format: http://localhost:<NGINX_HOST_PORT>,http://localhost:<FRONTEND_HOST_PORT>
# Update these if you change the host ports above
CORS_ORIGINS=http://localhost,http://localhost:8420,http://localhost:3420
# =============================================================================
# Rate Limiting
# =============================================================================
RATE_LIMIT_DEFAULT=100/minute
RATE_LIMIT_AUTH=20/minute
# =============================================================================
# Frontend (Vite)
# =============================================================================
VITE_API_URL=/api
VITE_API_TARGET=http://localhost:8000
VITE_APP_TITLE=My App

View File

@ -1,39 +0,0 @@
# AngelaMos | 2025
# dev.compose.yml
venv
*.venv
*.env
*.cache
*.egg
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.tox/
.mypy_cache/
.dmypy.json
dmypy.json
.DS_Store

View File

@ -1,36 +0,0 @@
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.8
hooks:
- id: ruff
args: ["backend/"]
always_run: true
- repo: local
hooks:
- id: mypy-check
name: MyPy Type Checking
entry: bash -c 'cd backend && mypy .'
language: system
types: [python]
pass_filenames: false
always_run: true
- id: pylint-check
name: PyLint Code Quality
entry: bash -c 'cd backend && pylint .'
language: system
types: [python]
pass_filenames: false
always_run: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-toml
- id: debug-statements
- id: end-of-file-fixer
- id: check-yaml
args: [--allow-multiple-documents]

View File

@ -1,109 +0,0 @@
===========================================
Fullstack Template: FastAPI + React + Nginx
===========================================
*Production-ready Docker setup with TypeScript and SCSS*
----
Setup
=====
Option 1: GitHub Template (Recommended)
---------------------------------------
Click the **"Use this template"** button above, or:
.. code-block:: bash
gh repo create my-project --template CarterPerez-dev/fullstack-template
cd my-project
Option 2: Clone
---------------
.. code-block:: bash
git clone https://github.com/CarterPerez-dev/fullstack-template.git my-project
cd my-project
rm -rf .git && git init
Prerequisites (Optional but Recommended)
----------------------------------------
Install `just <https://github.com/casey/just>`_ command runner:
.. code-block:: bash
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to ~/bin
Then add ``~/bin`` to your PATH if not already.
Installation
------------
.. code-block:: bash
chmod +x setup.sh
./setup.sh
Or if you have just:
.. code-block:: bash
just setup
This will:
- Copy ``.env.example````.env`` with generated ``SECRET_KEY``
- Move template files (LICENSE, CONTRIBUTING, etc.) to root
- Install backend dependencies (uv sync)
- Install frontend dependencies (pnpm install)
Next Steps
----------
1. Edit ``.env`` with your configuration
2. Start development: ``just dev-up``
3. After creating models: ``just migration-local "initial"`` then ``just migrate-local head``
Run ``just`` to see all available commands.
----
Documentation
=============
Comprehensive guides for understanding and customizing this template:
Backend
-------
- `Backend Architecture <https://github.com/CarterPerez-dev/fullstack-template/blob/documentation/docs/wiki/backend.md>`_
Complete backend architecture including FastAPI setup, security patterns (JWT + Argon2id), database models, repository pattern, services, API endpoints, testing, and production deployment.
Frontend
--------
- `Frontend Architecture <https://github.com/CarterPerez-dev/fullstack-template/blob/documentation/docs/wiki/frontend.md>`_
React 19 + TypeScript architecture with TanStack Query, Zustand state management, complete design system (OKLCH colors), API integration patterns, SCSS utilities, and performance optimizations.
Infrastructure
--------------
- `Nginx Configuration <https://github.com/CarterPerez-dev/fullstack-template/blob/documentation/docs/wiki/nginx.md>`_
Reverse proxy setup, rate limiting, WebSocket proxying, caching strategies, security headers, and performance tuning for both development and production.
- `Docker & Compose <https://github.com/CarterPerez-dev/fullstack-template/blob/documentation/docs/wiki/docker.md>`_
Multi-stage Dockerfiles, health checks, network segmentation, resource limits, security hardening, and complete containerization guide.
----
License
=======
MIT License - see - `LICENSE <https://github.com/CarterPerez-dev/fullstack-template/blob/documentation/docs/templates/LICENSE>`_

View File

@ -1,85 +0,0 @@
# ===============================
# © AngelaMos | 2025
# .dockerignore
# ===============================
# Virtual environments
.venv/
venv/
ENV/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.pyo
# Type checker / linter caches
.mypy_cache/
.ruff_cache/
.pytest_cache/
.ty_cache/
# Coverage
.coverage
.coverage.*
htmlcov/
coverage.xml
*.cover
# Build artifacts
dist/
build/
*.egg-info/
*.egg
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
# Git
.git/
.gitignore
.gitattributes
# Docker (don't need these in the image)
Dockerfile*
docker-compose*
.dockerignore
# Tests (not needed in prod image)
tests/
conftest.py
pytest.ini
.coveragerc
# Documentation
docs/
*.md
*.rst
LICENSE
# Logs
*.log
logs/
# Local databases
*.db
*.sqlite
*.sqlite3
# Alembic versions not needed in image (migrations run separately)
# alembic/versions/
# Misc
.DS_Store
Thumbs.db
*.bak

View File

@ -1,46 +0,0 @@
[style]
based_on_style = pep8
column_limit = 75
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

View File

@ -1,43 +0,0 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -1,118 +0,0 @@
"""
AngelaMos | 2025
env.py
"""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from config import settings
from core.Base import Base
from core.enums import SafeEnum
from user.User import User
from auth.RefreshToken import RefreshToken
config = context.config
def render_item(type_, obj, autogen_context):
"""
Custom renderer for alembic autogenerate.
Converts SafeEnum to standard sa.Enum and ensures DateTime uses timezone.
"""
import sqlalchemy as sa
if isinstance(obj, SafeEnum):
enum_class = obj.enum_class
values = [e.value for e in enum_class]
return f"sa.Enum({', '.join(repr(v) for v in values)}, name='{obj.name}')"
if isinstance(obj, sa.DateTime):
return "sa.DateTime(timezone=True)"
return False
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url() -> str:
"""
Get database URL from settings
"""
return str(settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode
"""
url = get_url()
context.configure(
url = url,
target_metadata = target_metadata,
literal_binds = True,
dialect_opts = {"paramstyle": "named"},
compare_type = True,
compare_server_default = True,
render_item = render_item,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""
Run migrations with connection
"""
context.configure(
connection = connection,
target_metadata = target_metadata,
compare_type = True,
compare_server_default = True,
render_item = render_item,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""
Run migrations in async mode
"""
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix = "sqlalchemy.",
poolclass = pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode
"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -1,24 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -1,28 +0,0 @@
"""initial_schema
Revision ID: ee8bde7caa8b
Revises:
Create Date: 2025-12-07 21:27:04.856781
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'ee8bde7caa8b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -1,24 +0,0 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -1,19 +0,0 @@
"""
AngelaMos | 2025
__main__.py
"""
import uvicorn
from config import settings
from factory import create_app
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"__main__:app",
host = settings.HOST,
port = settings.PORT,
reload = settings.RELOAD,
)

View File

@ -1,147 +0,0 @@
"""
AngelaMos | 2025
routes.py
"""
from uuid import UUID
from typing import Annotated
from fastapi import (
APIRouter,
Depends,
Query,
status,
)
from config import (
settings,
UserRole,
)
from core.dependencies import RequireRole
from core.responses import (
AUTH_401,
CONFLICT_409,
FORBIDDEN_403,
NOT_FOUND_404,
)
from user.schemas import (
AdminUserCreate,
UserListResponse,
UserResponse,
UserUpdateAdmin,
)
from user.User import User
from user.dependencies import UserServiceDep
router = APIRouter(prefix = "/admin", tags = ["admin"])
AdminOnly = Annotated[User, Depends(RequireRole(UserRole.ADMIN))]
@router.get(
"/users",
response_model = UserListResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403
},
)
async def list_users(
user_service: UserServiceDep,
_: AdminOnly,
page: int = Query(default = 1,
ge = 1),
size: int = Query(
default = settings.PAGINATION_DEFAULT_SIZE,
ge = 1,
le = settings.PAGINATION_MAX_SIZE
),
) -> UserListResponse:
"""
List all users (admin only)
"""
return await user_service.list_users(page, size)
@router.post(
"/users",
response_model = UserResponse,
status_code = status.HTTP_201_CREATED,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**CONFLICT_409
},
)
async def create_user(
user_service: UserServiceDep,
_: AdminOnly,
user_data: AdminUserCreate,
) -> UserResponse:
"""
Create a new user (admin only, bypasses registration)
"""
return await user_service.admin_create_user(user_data)
@router.get(
"/users/{user_id}",
response_model = UserResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def get_user(
user_service: UserServiceDep,
_: AdminOnly,
user_id: UUID,
) -> UserResponse:
"""
Get user by ID (admin only)
"""
return await user_service.get_user_by_id(user_id)
@router.patch(
"/users/{user_id}",
response_model = UserResponse,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404,
**CONFLICT_409
},
)
async def update_user(
user_service: UserServiceDep,
_: AdminOnly,
user_id: UUID,
user_data: UserUpdateAdmin,
) -> UserResponse:
"""
Update user (admin only)
"""
return await user_service.admin_update_user(user_id, user_data)
@router.delete(
"/users/{user_id}",
status_code = status.HTTP_204_NO_CONTENT,
responses = {
**AUTH_401,
**FORBIDDEN_403,
**NOT_FOUND_404
},
)
async def delete_user(
user_service: UserServiceDep,
_: AdminOnly,
user_id: UUID,
) -> None:
"""
Delete user (admin only, hard delete)
"""
await user_service.admin_delete_user(user_id)

View File

@ -1,117 +0,0 @@
"""
AngelaMos | 2025
RefreshToken.py
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import UUID
import uuid6
from sqlalchemy import (
DateTime,
ForeignKey,
String,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
DEVICE_ID_MAX_LENGTH,
DEVICE_NAME_MAX_LENGTH,
IP_ADDRESS_MAX_LENGTH,
TOKEN_HASH_LENGTH,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from user.User import User
class RefreshToken(Base, UUIDMixin, TimestampMixin):
"""
Refresh token for JWT authentication
Tokens are stored as SHA 256 hashes, never raw
Family ID enables detection of token reuse attacks
"""
__tablename__ = "refresh_tokens"
token_hash: Mapped[str] = mapped_column(
String(TOKEN_HASH_LENGTH),
unique = True,
index = True,
)
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id",
ondelete = "CASCADE"),
index = True,
)
family_id: Mapped[UUID] = mapped_column(
default = uuid6.uuid7,
index = True,
)
device_id: Mapped[str | None] = mapped_column(
String(DEVICE_ID_MAX_LENGTH),
default = None,
)
device_name: Mapped[str | None] = mapped_column(
String(DEVICE_NAME_MAX_LENGTH),
default = None,
)
ip_address: Mapped[str | None] = mapped_column(
String(IP_ADDRESS_MAX_LENGTH),
default = None,
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone = True),
index = True,
)
is_revoked: Mapped[bool] = mapped_column(default = False)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
)
user: Mapped[User] = relationship(back_populates = "refresh_tokens")
def revoke(self) -> None:
"""
Revoke this token
"""
self.is_revoked = True
self.revoked_at = datetime.now(UTC)
@property
def is_expired(self) -> bool:
"""
Check if token has expired
Handles both timezone aware and naive datetimes for SQLite compatibility
"""
now = datetime.now(UTC)
expires = self.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo = UTC)
return now > expires
@property
def is_valid(self) -> bool:
"""
Check if token is usable
"""
return not self.is_revoked and not self.is_expired

View File

@ -1,21 +0,0 @@
"""
AngelaMos | 2025
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from core.dependencies import DBSession
from .service import AuthService
def get_auth_service(db: DBSession) -> AuthService:
"""
Dependency to inject AuthService instance
"""
return AuthService(db)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]

View File

@ -1,178 +0,0 @@
"""
AngelaMos | 2025
repository.py
"""
from uuid import UUID
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from .RefreshToken import RefreshToken
from core.base_repository import BaseRepository
class RefreshTokenRepository(BaseRepository[RefreshToken]):
"""
Repository for RefreshToken model database operations
"""
model = RefreshToken
@classmethod
async def get_by_hash(
cls,
session: AsyncSession,
token_hash: str,
) -> RefreshToken | None:
"""
Get refresh token by its hash
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash
)
)
return result.scalars().first()
@classmethod
async def get_valid_by_hash(
cls,
session: AsyncSession,
token_hash: str,
) -> RefreshToken | None:
"""
Get valid (not revoked, not expired) refresh token by hash
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash,
RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(UTC),
)
)
return result.scalars().first()
@classmethod
async def create_token(
cls,
session: AsyncSession,
user_id: UUID,
token_hash: str,
family_id: UUID,
expires_at: datetime,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> RefreshToken:
"""
Create a new refresh token
"""
token = RefreshToken(
user_id = user_id,
token_hash = token_hash,
family_id = family_id,
expires_at = expires_at,
device_id = device_id,
device_name = device_name,
ip_address = ip_address,
)
session.add(token)
await session.flush()
await session.refresh(token)
return token
@classmethod
async def revoke_token(
cls,
session: AsyncSession,
token: RefreshToken,
) -> RefreshToken:
"""
Revoke a single token
"""
token.revoke()
await session.flush()
await session.refresh(token)
return token
@classmethod
async def revoke_family(
cls,
session: AsyncSession,
family_id: UUID,
) -> int:
"""
Revoke all tokens in a family (for replay attack response)
Returns count of revoked tokens
"""
result = await session.execute(
update(RefreshToken).where(
RefreshToken.family_id == family_id,
RefreshToken.is_revoked == False,
).values(is_revoked = True,
revoked_at = datetime.now(UTC))
)
await session.flush()
return result.rowcount or 0
@classmethod
async def revoke_all_user_tokens(
cls,
session: AsyncSession,
user_id: UUID,
) -> int:
"""
Revoke all tokens for a user (logout all devices)
Returns count of revoked tokens
"""
result = await session.execute(
update(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False,
).values(is_revoked = True,
revoked_at = datetime.now(UTC))
)
await session.flush()
return result.rowcount or 0
@classmethod
async def get_user_active_sessions(
cls,
session: AsyncSession,
user_id: UUID,
) -> list[RefreshToken]:
"""
Get all active sessions for a user
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False,
RefreshToken.expires_at > datetime.now(UTC),
)
)
return list(result.scalars().all())
@classmethod
async def cleanup_expired(
cls,
session: AsyncSession,
) -> int:
"""
Delete expired tokens (for maintenance job)
Returns count of deleted tokens
"""
result = await session.execute(
select(RefreshToken).where(
RefreshToken.expires_at < datetime.now(UTC)
)
)
tokens = result.scalars().all()
for token in tokens:
await session.delete(token)
await session.flush()
return len(tokens)

View File

@ -1,151 +0,0 @@
"""
AngelaMos | 2025
routes.py
"""
from typing import Annotated
from fastapi import (
APIRouter,
Cookie,
Depends,
Request,
Response,
status,
)
from fastapi.security import (
OAuth2PasswordRequestForm,
)
from config import settings
from core.dependencies import (
ClientIP,
CurrentUser,
)
from core.security import (
clear_refresh_cookie,
set_refresh_cookie,
)
from core.rate_limit import limiter
from core.exceptions import TokenError
from .schemas import (
PasswordChange,
TokenResponse,
TokenWithUserResponse,
)
from user.schemas import UserResponse
from .dependencies import AuthServiceDep
from user.dependencies import UserServiceDep
from core.responses import AUTH_401
router = APIRouter(prefix = "/auth", tags = ["auth"])
@router.post(
"/login",
response_model = TokenWithUserResponse,
responses = {**AUTH_401}
)
@limiter.limit(settings.RATE_LIMIT_AUTH)
async def login(
request: Request,
response: Response,
auth_service: AuthServiceDep,
ip: ClientIP,
form_data: Annotated[OAuth2PasswordRequestForm,
Depends()],
) -> TokenWithUserResponse:
"""
Login with email and password
"""
result, refresh_token = await auth_service.login(
email=form_data.username,
password=form_data.password,
ip_address=ip,
)
set_refresh_cookie(response, refresh_token)
return result
@router.post(
"/refresh",
response_model = TokenResponse,
responses = {**AUTH_401}
)
async def refresh_token(
auth_service: AuthServiceDep,
ip: ClientIP,
refresh_token: str | None = Cookie(None),
) -> TokenResponse:
"""
Refresh access token
"""
if not refresh_token:
raise TokenError("Refresh token required")
return await auth_service.refresh_tokens(
refresh_token,
ip_address = ip
)
@router.post(
"/logout",
status_code = status.HTTP_204_NO_CONTENT,
responses = {**AUTH_401}
)
async def logout(
response: Response,
auth_service: AuthServiceDep,
refresh_token: str | None = Cookie(None),
) -> None:
"""
Logout current session
"""
if not refresh_token:
raise TokenError("Refresh token required")
await auth_service.logout(refresh_token)
clear_refresh_cookie(response)
@router.post("/logout-all", responses = {**AUTH_401})
async def logout_all(
response: Response,
auth_service: AuthServiceDep,
current_user: CurrentUser,
) -> dict[str,
int]:
"""
Logout from all devices
"""
count = await auth_service.logout_all(current_user)
clear_refresh_cookie(response)
return {"revoked_sessions": count}
@router.get("/me", response_model = UserResponse, responses = {**AUTH_401})
async def get_current_user(current_user: CurrentUser) -> UserResponse:
"""
Get current authenticated user
"""
return UserResponse.model_validate(current_user)
@router.post(
"/change-password",
status_code = status.HTTP_204_NO_CONTENT,
responses = {**AUTH_401}
)
async def change_password(
user_service: UserServiceDep,
current_user: CurrentUser,
data: PasswordChange,
) -> None:
"""
Change current user password
"""
await user_service.change_password(
current_user,
data.current_password,
data.new_password,
)

View File

@ -1,78 +0,0 @@
"""
AngelaMos | 2025
schemas.py
"""
from pydantic import (
Field,
EmailStr,
)
from config import (
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
)
from core.base_schema import BaseSchema
from user.schemas import UserResponse
class LoginRequest(BaseSchema):
"""
Schema for login request
"""
email: EmailStr
password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
class TokenResponse(BaseSchema):
"""
Schema for token response
"""
access_token: str
token_type: str = "bearer"
class TokenWithUserResponse(TokenResponse):
"""
Schema for login response with user data
"""
user: UserResponse
class RefreshTokenRequest(BaseSchema):
"""
Schema for refresh token request via body
"""
refresh_token: str
class PasswordResetRequest(BaseSchema):
"""
Schema for password reset request
"""
email: EmailStr
class PasswordResetConfirm(BaseSchema):
"""
Schema for password reset confirmation
"""
token: str
new_password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
class PasswordChange(BaseSchema):
"""
Schema for changing password while authenticated
"""
current_password: str
new_password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)

View File

@ -1,205 +0,0 @@
"""
AngelaMos | 2025
service.py
"""
import uuid6
from sqlalchemy.ext.asyncio import (
AsyncSession,
)
from core.exceptions import (
InvalidCredentials,
TokenError,
TokenRevokedError,
)
from core.security import (
hash_token,
create_access_token,
create_refresh_token,
verify_password_with_timing_safety,
)
from user.User import User
from user.repository import UserRepository
from .repository import RefreshTokenRepository
from .schemas import (
TokenResponse,
TokenWithUserResponse,
)
from user.schemas import UserResponse
class AuthService:
"""
Business logic for authentication operations
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def authenticate(
self,
email: str,
password: str,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> tuple[str,
str,
User]:
"""
Authenticate user and create tokens
"""
user = await UserRepository.get_by_email(self.session, email)
hashed_password = user.hashed_password if user else None
is_valid, new_hash = await verify_password_with_timing_safety(
password, hashed_password
)
if not is_valid or user is None:
raise InvalidCredentials()
if not user.is_active:
raise InvalidCredentials()
if new_hash:
await UserRepository.update_password(self.session, user, new_hash)
access_token = create_access_token(user.id, user.token_version)
family_id = uuid6.uuid7()
raw_refresh, token_hash, expires_at = create_refresh_token(user.id, family_id)
await RefreshTokenRepository.create_token(
self.session,
user_id = user.id,
token_hash = token_hash,
family_id = family_id,
expires_at = expires_at,
device_id = device_id,
device_name = device_name,
ip_address = ip_address,
)
return access_token, raw_refresh, user
async def login(
self,
email: str,
password: str,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> tuple[TokenWithUserResponse,
str]:
"""
Login and return tokens with user data
"""
access_token, refresh_token, user = await self.authenticate(
email,
password,
device_id,
device_name,
ip_address,
)
response = TokenWithUserResponse(
access_token = access_token,
user = UserResponse.model_validate(user),
)
return response, refresh_token
async def refresh_tokens(
self,
refresh_token: str,
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> TokenResponse:
"""
Refresh access token using refresh token
Implements token rotation with replay attack detection
"""
token_hash = hash_token(refresh_token)
stored_token = await RefreshTokenRepository.get_by_hash(
self.session,
token_hash
)
if stored_token is None:
raise TokenError(message = "Invalid refresh token")
if stored_token.is_revoked:
await RefreshTokenRepository.revoke_family(
self.session,
stored_token.family_id
)
raise TokenRevokedError()
if stored_token.is_expired:
raise TokenError(message = "Refresh token expired")
user = await UserRepository.get_by_id(
self.session,
stored_token.user_id
)
if user is None or not user.is_active:
raise TokenError(message = "User not found or inactive")
await RefreshTokenRepository.revoke_token(self.session, stored_token)
access_token = create_access_token(user.id, user.token_version)
_, new_hash, expires_at = create_refresh_token(
user.id, stored_token.family_id
)
await RefreshTokenRepository.create_token(
self.session,
user_id = user.id,
token_hash = new_hash,
family_id = stored_token.family_id,
expires_at = expires_at,
device_id = device_id,
device_name = device_name,
ip_address = ip_address,
)
return TokenResponse(access_token = access_token)
async def logout(
self,
refresh_token: str,
) -> None:
"""
Logout by revoking refresh token
Silently succeeds if token is already revoked or doesn't exist
"""
token_hash = hash_token(refresh_token)
stored_token = await RefreshTokenRepository.get_by_hash(
self.session,
token_hash
)
if stored_token and not stored_token.is_revoked:
await RefreshTokenRepository.revoke_token(
self.session,
stored_token
)
async def logout_all(
self,
user: User,
) -> int:
"""
Logout from all devices
Returns count of revoked sessions
"""
await UserRepository.increment_token_version(self.session, user)
return await RefreshTokenRepository.revoke_all_user_tokens(
self.session,
user.id
)

View File

@ -1,162 +0,0 @@
"""
AngelaMos | 2025
config.py
"""
from pathlib import Path
from typing import Literal
from functools import lru_cache
from pydantic import (
Field,
RedisDsn,
SecretStr,
PostgresDsn,
model_validator,
)
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
)
from core.constants import (
API_PREFIX,
API_VERSION,
DEVICE_ID_MAX_LENGTH,
DEVICE_NAME_MAX_LENGTH,
EMAIL_MAX_LENGTH,
FULL_NAME_MAX_LENGTH,
IP_ADDRESS_MAX_LENGTH,
PASSWORD_HASH_MAX_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
TOKEN_HASH_LENGTH,
)
from core.enums import (
Environment,
HealthStatus,
SafeEnum,
TokenType,
UserRole,
)
__all__ = [
"API_PREFIX",
"API_VERSION",
"DEVICE_ID_MAX_LENGTH",
"DEVICE_NAME_MAX_LENGTH",
"EMAIL_MAX_LENGTH",
"FULL_NAME_MAX_LENGTH",
"IP_ADDRESS_MAX_LENGTH",
"PASSWORD_HASH_MAX_LENGTH",
"PASSWORD_MAX_LENGTH",
"PASSWORD_MIN_LENGTH",
"TOKEN_HASH_LENGTH",
"Environment",
"HealthStatus",
"SafeEnum",
"Settings",
"TokenType",
"UserRole",
"get_settings",
"settings",
]
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_ENV_FILE = _PROJECT_ROOT / ".env"
class Settings(BaseSettings):
"""
Application settings loaded from environment variables
"""
model_config = SettingsConfigDict(
env_file = _ENV_FILE,
env_file_encoding = "utf-8",
case_sensitive = False,
extra = "ignore",
)
APP_NAME: str = "FastAPI Template"
APP_VERSION: str = "1.0.0"
APP_SUMMARY: str = "FastAPI Backend Template"
APP_DESCRIPTION: str = "Async first boilerplate - JWT, Asyncdb, PostgreSQL"
APP_CONTACT_NAME: str = "AngelaMos"
APP_CONTACT_EMAIL: str = "support@certgames.com"
APP_LICENSE_NAME: str = "MIT"
APP_LICENSE_URL: str = "https://github.com/CarterPerez-dev/fullstack-template/docs/templates/MIT"
ENVIRONMENT: Environment = Environment.DEVELOPMENT
DEBUG: bool = False
HOST: str = "0.0.0.0"
PORT: int = 8000
RELOAD: bool = True
DATABASE_URL: PostgresDsn
DB_POOL_SIZE: int = Field(default = 20, ge = 5, le = 100)
DB_MAX_OVERFLOW: int = Field(default = 10, ge = 0, le = 50)
DB_POOL_TIMEOUT: int = Field(default = 30, ge = 10)
DB_POOL_RECYCLE: int = Field(default = 1800, ge = 300)
SECRET_KEY: SecretStr = Field(..., min_length = 32)
JWT_ALGORITHM: Literal["HS256", "HS384", "HS512"] = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default = 15, ge = 5, le = 60)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default = 7, ge = 1, le = 30)
REDIS_URL: RedisDsn | None = None
CORS_ORIGINS: list[str] = [
"http://localhost",
"http://localhost:3420",
"http://localhost:8420",
]
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: list[str] = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS"
]
CORS_ALLOW_HEADERS: list[str] = ["*"]
RATE_LIMIT_DEFAULT: str = "100/minute"
RATE_LIMIT_AUTH: str = "20/minute"
PAGINATION_DEFAULT_SIZE: int = Field(default = 20, ge = 1, le = 100)
PAGINATION_MAX_SIZE: int = Field(default = 100, ge = 1, le = 500)
LOG_LEVEL: Literal["DEBUG",
"INFO",
"WARNING",
"ERROR",
"CRITICAL"] = "INFO"
LOG_JSON_FORMAT: bool = True
@model_validator(mode = "after")
def validate_production_settings(self) -> "Settings":
"""
Enforce security constraints in production environment.
"""
if self.ENVIRONMENT == Environment.PRODUCTION:
if self.DEBUG:
raise ValueError("DEBUG must be False in production")
if self.CORS_ORIGINS == ["*"]:
raise ValueError(
"CORS_ORIGINS cannot be ['*'] in production"
)
return self
@lru_cache
def get_settings() -> Settings:
"""
Cached settings instance to avoid repeated env parsing
"""
return Settings()
settings = get_settings()

View File

@ -1,84 +0,0 @@
"""
AngelaMos | 2025
Base.py
"""
from uuid import UUID
from datetime import UTC, datetime
import uuid6
from sqlalchemy.orm import (
Mapped,
mapped_column,
DeclarativeBase,
)
from sqlalchemy import (
DateTime,
MetaData,
func,
)
from sqlalchemy.ext.asyncio import AsyncAttrs
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(AsyncAttrs, DeclarativeBase):
"""
Base class for all SQLAlchemy models
"""
metadata = MetaData(naming_convention = NAMING_CONVENTION)
class UUIDMixin:
"""
Mixin for UUID v7 primary key
UUID v7 is time sortable and distributed safe, optimal for B tree indexes
"""
id: Mapped[UUID] = mapped_column(
primary_key = True,
default = uuid6.uuid7,
)
class TimestampMixin:
"""
Mixin for created_at and updated_at timestamps
"""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone = True),
default = lambda: datetime.now(UTC),
server_default = func.now(),
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
onupdate = lambda: datetime.now(UTC),
server_onupdate = func.now(),
)
class SoftDeleteMixin:
"""
Mixin for soft delete functionality
"""
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone = True),
default = None,
)
is_deleted: Mapped[bool] = mapped_column(default = False)
def soft_delete(self) -> None:
self.is_deleted = True
self.deleted_at = datetime.now(UTC)
def restore(self) -> None:
self.is_deleted = False
self.deleted_at = None

View File

@ -1,4 +0,0 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -1,106 +0,0 @@
"""
AngelaMos | 2025
base_repository.py
"""
from collections.abc import Sequence
from typing import (
Any,
Generic,
TypeVar,
)
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from .Base import Base
ModelT = TypeVar("ModelT", bound = Base)
class BaseRepository(Generic[ModelT]):
"""
Generic repository with common CRUD operations
"""
model: type[ModelT]
@classmethod
async def get_by_id(
cls,
session: AsyncSession,
id: UUID,
) -> ModelT | None:
"""
Get a single record by ID
"""
return await session.get(cls.model, id)
@classmethod
async def get_multi(
cls,
session: AsyncSession,
skip: int = 0,
limit: int = 100,
) -> Sequence[ModelT]:
"""
Get multiple records with pagination
"""
result = await session.execute(
select(cls.model).offset(skip).limit(limit)
)
return result.scalars().all()
@classmethod
async def count(cls, session: AsyncSession) -> int:
"""
Count total records
"""
result = await session.execute(
select(func.count()).select_from(cls.model)
)
return result.scalar_one()
@classmethod
async def create(
cls,
session: AsyncSession,
**kwargs: Any,
) -> ModelT:
"""
Create a new record
"""
instance = cls.model(**kwargs)
session.add(instance)
await session.flush()
await session.refresh(instance)
return instance
@classmethod
async def update(
cls,
session: AsyncSession,
instance: ModelT,
**kwargs: Any,
) -> ModelT:
"""
Update an existing record
"""
for key, value in kwargs.items():
setattr(instance, key, value)
await session.flush()
await session.refresh(instance)
return instance
@classmethod
async def delete(
cls,
session: AsyncSession,
instance: ModelT,
) -> None:
"""
Delete a record
"""
await session.delete(instance)
await session.flush()

View File

@ -1,31 +0,0 @@
"""
AngelaMos | 2025
base.py
"""
from uuid import UUID
from datetime import datetime
from pydantic import (
BaseModel,
ConfigDict,
)
class BaseSchema(BaseModel):
"""
Base schema with common configuration
"""
model_config = ConfigDict(
from_attributes = True,
str_strip_whitespace = True,
)
class BaseResponseSchema(BaseSchema):
"""
Base schema for API responses with common fields
"""
id: UUID
created_at: datetime
updated_at: datetime | None = None

View File

@ -1,34 +0,0 @@
"""
AngelaMos | 2025
common_schemas.py
"""
from config import HealthStatus
from .base_schema import BaseSchema
class HealthResponse(BaseSchema):
"""
Health check response
"""
status: HealthStatus
environment: str
version: str
class HealthDetailedResponse(HealthResponse):
"""
Detailed health check with component status
"""
database: HealthStatus
redis: HealthStatus | None = None
class AppInfoResponse(BaseSchema):
"""
Root endpoint response with API information
"""
name: str
version: str
environment: str
docs_url: str | None

View File

@ -1,18 +0,0 @@
"""
AngelaMos | 2025
constants.py
"""
EMAIL_MAX_LENGTH = 320
PASSWORD_MIN_LENGTH = 8
PASSWORD_MAX_LENGTH = 128
PASSWORD_HASH_MAX_LENGTH = 1024
FULL_NAME_MAX_LENGTH = 255
TOKEN_HASH_LENGTH = 64
DEVICE_ID_MAX_LENGTH = 255
DEVICE_NAME_MAX_LENGTH = 100
IP_ADDRESS_MAX_LENGTH = 45
API_VERSION = "v1"
API_PREFIX = f"/{API_VERSION}"

View File

@ -1,160 +0,0 @@
"""
AngelaMos | 2025
database.py
"""
import contextlib
from collections.abc import (
AsyncIterator,
Iterator,
)
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
AsyncConnection,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import Session, sessionmaker
from config import settings
class DatabaseSessionManager:
"""
Manages database connections and sessions for both sync and async contexts
"""
def __init__(self) -> None:
self._async_engine: AsyncEngine | None = None
self._sync_engine: Engine | None = None
self._async_sessionmaker: async_sessionmaker[AsyncSession
] | None = None
self._sync_sessionmaker: sessionmaker[Session] | None = None
def init(self, database_url: str) -> None:
"""
Initialize database engines and session factories
"""
base_url = make_url(database_url)
async_url = base_url.set(drivername = "postgresql+asyncpg")
self._async_engine = create_async_engine(
async_url,
pool_size = settings.DB_POOL_SIZE,
max_overflow = settings.DB_MAX_OVERFLOW,
pool_timeout = settings.DB_POOL_TIMEOUT,
pool_recycle = settings.DB_POOL_RECYCLE,
pool_pre_ping = True,
echo = settings.DEBUG,
)
self._async_sessionmaker = async_sessionmaker(
bind = self._async_engine,
class_ = AsyncSession,
autocommit = False,
autoflush = False,
expire_on_commit = False,
)
sync_url = base_url.set(drivername = "postgresql+psycopg2")
self._sync_engine = create_engine(
sync_url,
pool_size = settings.DB_POOL_SIZE,
max_overflow = settings.DB_MAX_OVERFLOW,
pool_timeout = settings.DB_POOL_TIMEOUT,
pool_recycle = settings.DB_POOL_RECYCLE,
pool_pre_ping = True,
echo = settings.DEBUG,
)
self._sync_sessionmaker = sessionmaker(
bind = self._sync_engine,
autocommit = False,
autoflush = False,
expire_on_commit = False,
)
async def close(self) -> None:
"""
Dispose of all database connections
"""
if self._async_engine:
await self._async_engine.dispose()
self._async_engine = None
self._async_sessionmaker = None
if self._sync_engine:
self._sync_engine.dispose()
self._sync_engine = None
self._sync_sessionmaker = None
@contextlib.asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
"""
Async context manager for database sessions
Handles commit on success, rollback on exception
"""
if self._async_sessionmaker is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
session = self._async_sessionmaker()
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@contextlib.asynccontextmanager
async def connect(self) -> AsyncIterator[AsyncConnection]:
"""
Async context manager for raw database connections
"""
if self._async_engine is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
async with self._async_engine.begin() as connection:
yield connection
@property
def sync_engine(self) -> Engine:
"""
Sync engine for Alembic migrations
"""
if self._sync_engine is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
return self._sync_engine
@contextlib.contextmanager
def sync_session(self) -> Iterator[Session]:
"""
Sync context manager for migrations and CLI tools
"""
if self._sync_sessionmaker is None:
raise RuntimeError("DatabaseSessionManager is not initialized")
session = self._sync_sessionmaker()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
sessionmanager = DatabaseSessionManager()
async def get_db_session() -> AsyncIterator[AsyncSession]:
"""
FastAPI dependency for database sessions
"""
async with sessionmanager.session() as session:
yield session

View File

@ -1,146 +0,0 @@
"""
AngelaMos | 2025
dependencies.py
"""
from __future__ import annotations
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import Depends, Request
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
API_PREFIX,
TokenType,
UserRole,
)
from .database import get_db_session
from .exceptions import (
InactiveUser,
PermissionDenied,
TokenError,
TokenRevokedError,
UserNotFound,
)
from user.User import User
from .security import decode_access_token
from user.repository import UserRepository
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl = f"{API_PREFIX}/auth/login",
auto_error = True,
)
oauth2_scheme_optional = OAuth2PasswordBearer(
tokenUrl = f"{API_PREFIX}/auth/login",
auto_error = False,
)
DBSession = Annotated[AsyncSession, Depends(get_db_session)]
async def get_current_user(
token: Annotated[str,
Depends(oauth2_scheme)],
db: DBSession,
) -> User:
"""
Validate access token and return current user
"""
try:
payload = decode_access_token(token)
except jwt.InvalidTokenError as e:
raise TokenError(message = str(e)) from e
if payload.get("type") != TokenType.ACCESS.value:
raise TokenError(message = "Invalid token type")
user_id = UUID(payload["sub"])
user = await UserRepository.get_by_id(db, user_id)
if user is None:
raise UserNotFound(identifier = str(user_id))
if payload.get("token_version") != user.token_version:
raise TokenRevokedError()
return user
async def get_current_active_user(
user: Annotated[User,
Depends(get_current_user)],
) -> User:
"""
Ensure user is active
"""
if not user.is_active:
raise InactiveUser()
return user
async def get_optional_user(
token: Annotated[str | None,
Depends(oauth2_scheme_optional)],
db: DBSession,
) -> User | None:
"""
Return current user if authenticated, None otherwise
"""
if token is None:
return None
try:
payload = decode_access_token(token)
if payload.get("type") != TokenType.ACCESS.value:
return None
user_id = UUID(payload["sub"])
user = await UserRepository.get_by_id(db, user_id)
if user and user.token_version == payload.get("token_version"):
return user
except (jwt.InvalidTokenError, ValueError):
pass
return None
class RequireRole:
"""
Dependency class to check user role
"""
def __init__(self, *allowed_roles: UserRole) -> None:
self.allowed_roles = allowed_roles
async def __call__(
self,
user: Annotated[User,
Depends(get_current_active_user)],
) -> User:
if user.role not in self.allowed_roles:
raise PermissionDenied(
message =
f"Requires one of roles: {', '.join(r.value for r in self.allowed_roles)}",
)
return user
CurrentUser = Annotated["User", Depends(get_current_active_user)]
OptionalUser = Annotated["User | None", Depends(get_optional_user)]
def get_client_ip(request: Request) -> str:
"""
Extract client IP considering proxy headers
"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
ClientIP = Annotated[str, Depends(get_client_ip)]

View File

@ -1,81 +0,0 @@
"""
AngelaMos | 2025
enums.py
"""
from enum import Enum
from typing import Any
import sqlalchemy as sa
def enum_values_callable(enum_class: type[Enum]) -> list[str]:
"""
Returns enum VALUES (not names) for SQLAlchemy storage
Prevents the common trap where SQLAlchemy stores enum NAMES by default,
causing database breakage if you rename an enum member
"""
return [str(item.value) for item in enum_class]
class SafeEnum(sa.Enum):
"""
SQLAlchemy Enum type that stores VALUES and handles unknown values gracefully
https://blog.wrouesnel.com/posts/sqlalchemy-enums-careful-what-goes-into-the-database/
"""
def __init__(self, *enums: type[Enum], **kw: Any) -> None:
if "values_callable" not in kw:
kw["values_callable"] = enum_values_callable
super().__init__(*enums, **kw)
self._unknown_value = (
kw["_adapted_from"]._unknown_value
if "_adapted_from" in kw else kw.get("unknown_value")
)
def _object_value_for_elem(self, elem: str) -> Enum:
"""
Override to return unknown_value instead of raising LookupError
"""
try:
return self._object_lookup[elem]
except LookupError:
if self._unknown_value is not None:
return self._unknown_value
raise
class Environment(str, Enum):
"""
Application environment.
"""
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
class UserRole(str, Enum):
"""
User roles for authorization.
"""
UNKNOWN = "unknown"
USER = "user"
ADMIN = "admin"
class TokenType(str, Enum):
"""
JWT token types.
"""
ACCESS = "access"
REFRESH = "refresh"
class HealthStatus(str, Enum):
"""
Health check status values.
"""
HEALTHY = "healthy"
UNHEALTHY = "unhealthy"
DEGRADED = "degraded"

View File

@ -1,27 +0,0 @@
"""
AngelaMos | 2025
errors.py
"""
from typing import ClassVar
from pydantic import Field, ConfigDict
from core.base_schema import BaseSchema
class ErrorDetail(BaseSchema):
"""
Standard error response format
"""
detail: str = Field(..., description = "Human readable error message")
type: str = Field(..., description = "Exception class name")
model_config: ClassVar[ConfigDict] = ConfigDict(
json_schema_extra = {
"examples": [
{
"detail": "User with id '123' not found",
"type": "UserNotFound"
}
]
}
)

View File

@ -1,211 +0,0 @@
"""
AngelaMos | 2025
exceptions.py
"""
from typing import Any
class BaseAppException(Exception):
"""
Base exception for all application specific errors
"""
def __init__(
self,
message: str,
status_code: int = 500,
extra: dict[str,
Any] | None = None,
) -> None:
self.message = message
self.status_code = status_code
self.extra = extra or {}
super().__init__(self.message)
class ResourceNotFound(BaseAppException):
"""
Raised when a requested resource does not exist
"""
def __init__(
self,
resource: str,
identifier: str | int,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = f"{resource} with id '{identifier}' not found",
status_code = 404,
extra = extra,
)
self.resource = resource
self.identifier = identifier
class ConflictError(BaseAppException):
"""
Raised when an operation conflicts with existing state
"""
def __init__(
self,
message: str,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 409,
extra = extra
)
class ValidationError(BaseAppException):
"""
Raised when input validation fails outside of Pydantic
"""
def __init__(
self,
message: str,
field: str | None = None,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 422,
extra = extra
)
self.field = field
class AuthenticationError(BaseAppException):
"""
Raised when authentication fails
"""
def __init__(
self,
message: str = "Authentication failed",
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 401,
extra = extra
)
class TokenError(AuthenticationError):
"""
Raised for JWT token specific errors
"""
def __init__(
self,
message: str = "Invalid or expired token",
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(message = message, extra = extra)
class TokenRevokedError(TokenError):
"""
Raised when a revoked token is used
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(message = "Token has been revoked", extra = extra)
class PermissionDenied(BaseAppException):
"""
Raised when user lacks required permissions
"""
def __init__(
self,
message: str = "Permission denied",
required_permission: str | None = None,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 403,
extra = extra
)
self.required_permission = required_permission
class RateLimitExceeded(BaseAppException):
"""
Raised when rate limit is exceeded
"""
def __init__(
self,
message: str = "Calm down a little bit...",
retry_after: int | None = None,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
message = message,
status_code = 420,
extra = extra
)
self.retry_after = retry_after
class UserNotFound(ResourceNotFound):
"""
Raised when a user is not found
"""
def __init__(
self,
identifier: str | int,
extra: dict[str,
Any] | None = None,
) -> None:
super().__init__(
resource = "User",
identifier = identifier,
extra = extra
)
class EmailAlreadyExists(ConflictError):
"""
Raised when attempting to register with an existing email
"""
def __init__(
self,
email: str,
extra: dict[str,
Any] | None = None
) -> None:
super().__init__(
message = f"Email '{email}' is already registered",
extra = extra,
)
self.email = email
class InvalidCredentials(AuthenticationError):
"""
Raised when login credentials are invalid
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "Invalid email or password",
extra = extra
)
class InactiveUser(AuthenticationError):
"""
Raised when an inactive user attempts to authenticate.
"""
def __init__(self, extra: dict[str, Any] | None = None) -> None:
super().__init__(
message = "User account is inactive",
extra = extra
)

View File

@ -1,79 +0,0 @@
"""
AngelaMos | 2025
health_routes.py
"""
from fastapi import (
APIRouter,
status,
)
import redis.asyncio as redis
from sqlalchemy import text
from config import (
settings,
HealthStatus,
)
from .common_schemas import (
HealthResponse,
HealthDetailedResponse,
)
from .database import sessionmanager
router = APIRouter(tags = ["health"])
@router.get(
"/health",
response_model = HealthResponse,
status_code = status.HTTP_200_OK,
)
async def health_check() -> HealthResponse:
"""
Basic health check
"""
return HealthResponse(
status = HealthStatus.HEALTHY,
environment = settings.ENVIRONMENT.value,
version = settings.APP_VERSION,
)
@router.get(
"/health/detailed",
response_model = HealthDetailedResponse,
status_code = status.HTTP_200_OK,
)
async def health_check_detailed() -> HealthDetailedResponse:
"""
Detailed health check including database connectivity
"""
db_status = HealthStatus.UNHEALTHY
redis_status = None
try:
async with sessionmanager.connect() as conn:
await conn.execute(text("SELECT 1"))
db_status = HealthStatus.HEALTHY
except Exception:
db_status = HealthStatus.UNHEALTHY
if settings.REDIS_URL:
try:
r = redis.from_url(str(settings.REDIS_URL))
await r.ping()
redis_status = HealthStatus.HEALTHY
await r.close()
except Exception:
redis_status = HealthStatus.UNHEALTHY
overall = HealthStatus.HEALTHY if db_status == HealthStatus.HEALTHY else HealthStatus.DEGRADED
return HealthDetailedResponse(
status = overall,
environment = settings.ENVIRONMENT.value,
version = settings.APP_VERSION,
database = db_status,
redis = redis_status,
)

View File

@ -1,79 +0,0 @@
"""
AngelaMos | 2025
logging.py
"""
import logging
import sys
import structlog
from structlog.types import Processor
from config import (
settings,
Environment,
)
def configure_logging() -> None:
"""
Structlog with appropriate processors for the environment
"""
shared_processors: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt = "iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.UnicodeDecoder(),
]
if settings.ENVIRONMENT == Environment.PRODUCTION:
shared_processors.append(structlog.processors.format_exc_info)
renderer: Processor = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer(colors = True)
structlog.configure(
processors = shared_processors + [
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory = structlog.stdlib.LoggerFactory(),
wrapper_class = structlog.stdlib.BoundLogger,
cache_logger_on_first_use = True,
)
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain = shared_processors,
processors = [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(settings.LOG_LEVEL)
for logger_name in ["uvicorn",
"uvicorn.access",
"uvicorn.error",
"sqlalchemy.engine"]:
logger = logging.getLogger(logger_name)
logger.handlers.clear()
logger.addHandler(handler)
logger.propagate = False
if settings.ENVIRONMENT != Environment.DEVELOPMENT:
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
"""
Get a structured logger instance
"""
return structlog.get_logger(name)

View File

@ -1,44 +0,0 @@
"""
AngelaMos | 2025
rate_limit.py
"""
import jwt
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.requests import Request
from config import settings
def get_identifier(request: Request) -> str:
"""
Get rate limit identifier
Uses user ID if authenticated, otherwise falls back to IP address
(Will add more fingerprinting if needed depending on project)
"""
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
token = auth_header.split(" ")[1]
payload = jwt.decode(
token,
options = {"verify_signature": False},
)
user_id = payload.get("sub")
if user_id:
return f"user:{user_id}"
except Exception:
pass
return get_remote_address(request)
limiter = Limiter(
key_func = get_identifier,
storage_uri = str(settings.REDIS_URL) if settings.REDIS_URL else None,
default_limits = [settings.RATE_LIMIT_DEFAULT],
headers_enabled = True,
in_memory_fallback_enabled = True,
)

View File

@ -1,45 +0,0 @@
"""
AngelaMos | 2025
responses.py
"""
from typing import Any
from .error_schemas import ErrorDetail
AUTH_401: dict[int | str,
dict[str,
Any]] = {
401: {
"model": ErrorDetail,
"description": "Authentication failed"
},
}
FORBIDDEN_403: dict[int | str,
dict[str,
Any]] = {
403: {
"model": ErrorDetail,
"description": "Permission denied"
},
}
NOT_FOUND_404: dict[int | str,
dict[str,
Any]] = {
404: {
"model": ErrorDetail,
"description": "Resource not found"
},
}
CONFLICT_409: dict[int | str,
dict[str,
Any]] = {
409: {
"model": ErrorDetail,
"description": "Resource conflict"
},
}

View File

@ -1,194 +0,0 @@
"""
AngelaMos | 2025
security.py
"""
import asyncio
import hashlib
import secrets
from datetime import (
UTC,
datetime,
timedelta,
)
from typing import Any
from uuid import UUID
import jwt
from fastapi import Response
from pwdlib import PasswordHash
from config import (
API_PREFIX,
settings,
TokenType,
)
password_hasher = PasswordHash.recommended()
async def hash_password(password: str) -> str:
"""
Hash password using Argon2id
Runs in thread pool to avoid blocking the async event loop
since Argon2 is CPU intensive by design
"""
return await asyncio.to_thread(password_hasher.hash, password)
async def verify_password(plain_password: str,
hashed_password: str) -> tuple[bool,
str | None]:
"""
Verify password and check if rehash is needed
Returns:
Tuple of (is_valid, new_hash_if_needs_rehash)
If password is valid but hash params are outdated, returns new hash
"""
try:
return await asyncio.to_thread(
password_hasher.verify_and_update,
plain_password,
hashed_password
)
except Exception:
return False, None
DUMMY_HASH = password_hasher.hash(
"dummy_password_for_timing_attack_prevention"
)
async def verify_password_with_timing_safety(
plain_password: str,
hashed_password: str | None,
) -> tuple[bool,
str | None]:
"""
Verify password with constant time behavior to prevent user enumeration
If no hash is provided (user doesn't exist), still performs a dummy
hash operation to prevent timing attacks
"""
if hashed_password is None:
await asyncio.to_thread(
password_hasher.verify,
plain_password,
DUMMY_HASH
)
return False, None
return await verify_password(plain_password, hashed_password)
def create_access_token(
user_id: UUID,
token_version: int,
extra_claims: dict[str,
Any] | None = None,
) -> str:
"""
Create a short lived access token
"""
now = datetime.now(UTC)
payload = {
"sub": str(user_id),
"type": TokenType.ACCESS.value,
"token_version": token_version,
"iat": now,
"exp":
now + timedelta(minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES),
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(
payload,
settings.SECRET_KEY.get_secret_value(),
algorithm = settings.JWT_ALGORITHM,
)
def create_refresh_token(
user_id: UUID,
family_id: UUID,
) -> tuple[str,
str,
datetime]:
"""
Create a long lived refresh token
Returns:
Tuple of (raw_token, token_hash, expires_at)
Raw token is sent to client, hash is stored in database
"""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.now(UTC) + timedelta(
days = settings.REFRESH_TOKEN_EXPIRE_DAYS
)
return raw_token, token_hash, expires_at
def decode_access_token(token: str) -> dict[str, Any]:
"""
Decode and validate an access token
Raises:
jwt.InvalidTokenError: If token is invalid or expired
"""
return jwt.decode(
token,
settings.SECRET_KEY.get_secret_value(),
algorithms = [settings.JWT_ALGORITHM],
options = {
"require": ["exp",
"sub",
"iat",
"type",
"token_version"]
},
)
def hash_token(token: str) -> str:
"""
Hash a token for secure storage
"""
return hashlib.sha256(token.encode()).hexdigest()
def generate_secure_token(nbytes: int = 32) -> str:
"""
Generate a cryptographically secure random token
"""
return secrets.token_urlsafe(nbytes)
def set_refresh_cookie(response: Response, token: str) -> None:
"""
Set refresh token as HttpOnly cookie
"""
response.set_cookie(
key = "refresh_token",
value = token,
httponly = True,
secure = settings.ENVIRONMENT.value != "development",
samesite = "strict",
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
path = f"{API_PREFIX}/auth",
)
def clear_refresh_cookie(response: Response) -> None:
"""
Clear refresh token cookie
"""
response.delete_cookie(
key = "refresh_token",
path = f"{API_PREFIX}/auth"
)

View File

@ -1,131 +0,0 @@
"""
AngelaMos | 2025
factory.py
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from config import settings, Environment, API_PREFIX
from core.database import sessionmanager
from core.exceptions import BaseAppException
from core.logging import configure_logging
from core.rate_limit import limiter
from middleware.correlation import CorrelationIdMiddleware
from core.common_schemas import AppInfoResponse
from core.health_routes import router as health_router
from user.routes import router as user_router
from auth.routes import router as auth_router
from admin.routes import router as admin_router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""
Application lifespan handler for startup and shutdown
"""
configure_logging()
sessionmanager.init(str(settings.DATABASE_URL))
yield
await sessionmanager.close()
OPENAPI_TAGS = [
{
"name": "root",
"description": "API information"
},
{
"name": "health",
"description": "Health check endpoints"
},
{
"name": "auth",
"description": "Authentication and authorization"
},
{
"name": "users",
"description": "User registration and profile management"
},
{
"name": "admin",
"description": "Admin only operations"
},
]
def create_app() -> FastAPI:
"""
Application factory
"""
is_production = settings.ENVIRONMENT == Environment.PRODUCTION
app = FastAPI(
title = settings.APP_NAME,
summary = settings.APP_SUMMARY,
description = settings.APP_DESCRIPTION,
version = settings.APP_VERSION,
contact = {
"name": settings.APP_CONTACT_NAME,
"email": settings.APP_CONTACT_EMAIL,
},
license_info = {
"name": settings.APP_LICENSE_NAME,
"url": settings.APP_LICENSE_URL,
},
openapi_tags = OPENAPI_TAGS,
lifespan = lifespan,
openapi_url = None if is_production else "/openapi.json",
docs_url = None if is_production else "/docs",
redoc_url = None if is_production else "/redoc",
)
app.add_middleware(CorrelationIdMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins = settings.CORS_ORIGINS,
allow_credentials = settings.CORS_ALLOW_CREDENTIALS,
allow_methods = settings.CORS_ALLOW_METHODS,
allow_headers = settings.CORS_ALLOW_HEADERS,
)
app.state.limiter = limiter
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler
)
@app.exception_handler(BaseAppException)
async def app_exception_handler(
request: Request,
exc: BaseAppException,
) -> JSONResponse:
return JSONResponse(
status_code = exc.status_code,
content = {
"detail": exc.message,
"type": exc.__class__.__name__,
},
)
@app.get("/", response_model = AppInfoResponse, tags = ["root"])
async def root() -> AppInfoResponse:
return AppInfoResponse(
name = settings.APP_NAME,
version = settings.APP_VERSION,
environment = settings.ENVIRONMENT.value,
docs_url = None if is_production else "/docs",
)
app.include_router(health_router)
app.include_router(admin_router, prefix = API_PREFIX)
app.include_router(auth_router, prefix = API_PREFIX)
app.include_router(user_router, prefix = API_PREFIX)
return app

View File

@ -1,11 +0,0 @@
"""
AngelaMos | 2025
__init__.py
"""
from .correlation import CorrelationIdMiddleware
__all__ = [
"CorrelationIdMiddleware",
]

View File

@ -1,43 +0,0 @@
"""
AngelaMos | 2025
correlation.py
"""
import uuid
from collections.abc import (
Awaitable,
Callable,
)
import structlog
from starlette.requests import Request
from starlette.responses import Response
from starlette.middleware.base import BaseHTTPMiddleware
RequestResponseEndpoint = Callable[[Request], Awaitable[Response]]
class CorrelationIdMiddleware(BaseHTTPMiddleware):
"""
Correlation ID to requests for distributed tracing
"""
async def dispatch(
self,
request: Request,
call_next: RequestResponseEndpoint,
) -> Response:
correlation_id = request.headers.get(
"X-Correlation-ID",
str(uuid.uuid4())
)
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
correlation_id = correlation_id,
method = request.method,
path = request.url.path,
)
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response

View File

@ -1,75 +0,0 @@
"""
AngelaMos | 2025
User.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import (
Mapped,
mapped_column,
relationship,
)
from config import (
EMAIL_MAX_LENGTH,
FULL_NAME_MAX_LENGTH,
PASSWORD_HASH_MAX_LENGTH,
SafeEnum,
UserRole,
)
from core.Base import (
Base,
TimestampMixin,
UUIDMixin,
)
if TYPE_CHECKING:
from auth.RefreshToken import RefreshToken
class User(Base, UUIDMixin, TimestampMixin):
"""
User account model
"""
__tablename__ = "users"
email: Mapped[str] = mapped_column(
String(EMAIL_MAX_LENGTH),
unique = True,
index = True,
)
hashed_password: Mapped[str] = mapped_column(
String(PASSWORD_HASH_MAX_LENGTH)
)
full_name: Mapped[str | None] = mapped_column(
String(FULL_NAME_MAX_LENGTH),
default = None,
)
is_active: Mapped[bool] = mapped_column(default = True)
is_verified: Mapped[bool] = mapped_column(default = False)
role: Mapped[UserRole] = mapped_column(
SafeEnum(UserRole,
unknown_value = UserRole.UNKNOWN),
default = UserRole.USER,
)
token_version: Mapped[int] = mapped_column(default = 0)
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
back_populates = "user",
cascade = "all, delete-orphan",
lazy = "raise",
)
def increment_token_version(self) -> None:
"""
Invalidate all existing tokens for this user
"""
self.token_version += 1

View File

@ -1,21 +0,0 @@
"""
AngelaMos | 2025
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from core.dependencies import DBSession
from .service import UserService
def get_user_service(db: DBSession) -> UserService:
"""
Dependency to inject UserService instance
"""
return UserService(db)
UserServiceDep = Annotated[UserService, Depends(get_user_service)]

View File

@ -1,108 +0,0 @@
"""
AngelaMos | 2025
repository.py
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .User import User
from core.base_repository import BaseRepository
class UserRepository(BaseRepository[User]):
"""
Repository for User model database operations
"""
model = User
@classmethod
async def get_by_email(
cls,
session: AsyncSession,
email: str,
) -> User | None:
"""
Get user by email address
"""
result = await session.execute(
select(User).where(User.email == email)
)
return result.scalars().first()
@classmethod
async def get_by_id(
cls,
session: AsyncSession,
id: UUID,
) -> User | None:
"""
Get user by ID
"""
return await session.get(User, id)
@classmethod
async def email_exists(
cls,
session: AsyncSession,
email: str,
) -> bool:
"""
Check if email is already registered
"""
result = await session.execute(
select(User.id).where(User.email == email)
)
return result.scalars().first() is not None
@classmethod
async def create_user(
cls,
session: AsyncSession,
email: str,
hashed_password: str,
full_name: str | None = None,
) -> User:
"""
Create a new user
"""
user = User(
email = email,
hashed_password = hashed_password,
full_name = full_name,
)
session.add(user)
await session.flush()
await session.refresh(user)
return user
@classmethod
async def update_password(
cls,
session: AsyncSession,
user: User,
hashed_password: str,
) -> User:
"""
Update user password and increment token version
"""
user.hashed_password = hashed_password
user.increment_token_version()
await session.flush()
await session.refresh(user)
return user
@classmethod
async def increment_token_version(
cls,
session: AsyncSession,
user: User,
) -> User:
"""
Invalidate all user tokens
"""
user.increment_token_version()
await session.flush()
await session.refresh(user)
return user

View File

@ -1,78 +0,0 @@
"""
AngelaMos | 2025
routes.py
"""
from uuid import UUID
from fastapi import (
APIRouter,
status,
)
from core.dependencies import CurrentUser
from core.responses import (
AUTH_401,
CONFLICT_409,
NOT_FOUND_404,
)
from .schemas import (
UserCreate,
UserResponse,
UserUpdate,
)
from .dependencies import UserServiceDep
router = APIRouter(prefix = "/users", tags = ["users"])
@router.post(
"",
response_model = UserResponse,
status_code = status.HTTP_201_CREATED,
responses = {**CONFLICT_409},
)
async def create_user(
user_service: UserServiceDep,
user_data: UserCreate,
) -> UserResponse:
"""
Register a new user
"""
return await user_service.create_user(user_data)
@router.get(
"/{user_id}",
response_model = UserResponse,
responses = {
**AUTH_401,
**NOT_FOUND_404
},
)
async def get_user(
user_service: UserServiceDep,
user_id: UUID,
_: CurrentUser,
) -> UserResponse:
"""
Get user by ID
"""
return await user_service.get_user_by_id(user_id)
@router.patch(
"/me",
response_model = UserResponse,
responses = {**AUTH_401},
)
async def update_current_user(
user_service: UserServiceDep,
current_user: CurrentUser,
user_data: UserUpdate,
) -> UserResponse:
"""
Update current user profile
"""
return await user_service.update_user(current_user, user_data)

View File

@ -1,109 +0,0 @@
"""
AngelaMos | 2025
schemas.py
"""
from pydantic import (
Field,
EmailStr,
field_validator,
)
from config import (
UserRole,
FULL_NAME_MAX_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
)
from core.base_schema import (
BaseSchema,
BaseResponseSchema,
)
class UserCreate(BaseSchema):
"""
Schema for user registration
"""
email: EmailStr
password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
full_name: str | None = Field(
default = None,
max_length = FULL_NAME_MAX_LENGTH
)
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
"""
Ensure password has minimum complexity
"""
if not any(c.isupper() for c in v):
raise ValueError(
"Password must contain at least one uppercase letter"
)
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
return v
class UserUpdate(BaseSchema):
"""
Schema for updating user profile
"""
full_name: str | None = Field(
default = None,
max_length = FULL_NAME_MAX_LENGTH
)
class UserUpdateAdmin(UserUpdate):
"""
Schema for admin updating user
"""
email: EmailStr | None = None
is_active: bool | None = None
is_verified: bool | None = None
role: UserRole | None = None
class AdminUserCreate(BaseSchema):
"""
Schema for admin creating a user
"""
email: EmailStr
password: str = Field(
min_length = PASSWORD_MIN_LENGTH,
max_length = PASSWORD_MAX_LENGTH
)
full_name: str | None = Field(
default = None,
max_length = FULL_NAME_MAX_LENGTH
)
role: UserRole = UserRole.USER
is_active: bool = True
is_verified: bool = False
class UserResponse(BaseResponseSchema):
"""
Schema for user API responses
"""
email: EmailStr
full_name: str | None
is_active: bool
is_verified: bool
role: UserRole
class UserListResponse(BaseSchema):
"""
Schema for paginated user list
"""
items: list[UserResponse]
total: int
page: int
size: int

View File

@ -1,212 +0,0 @@
"""
AngelaMos | 2025
service.py
"""
from uuid import UUID
from sqlalchemy.ext.asyncio import (
AsyncSession,
)
from core.exceptions import (
EmailAlreadyExists,
InvalidCredentials,
UserNotFound,
)
from core.security import (
hash_password,
verify_password,
)
from .schemas import (
AdminUserCreate,
UserCreate,
UserListResponse,
UserResponse,
UserUpdate,
UserUpdateAdmin,
)
from .User import User
from .repository import UserRepository
class UserService:
"""
Business logic for user operations
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def create_user(
self,
user_data: UserCreate,
) -> UserResponse:
"""
Register a new user
"""
if await UserRepository.email_exists(self.session, user_data.email):
raise EmailAlreadyExists(user_data.email)
hashed = await hash_password(user_data.password)
user = await UserRepository.create_user(
self.session,
email = user_data.email,
hashed_password = hashed,
full_name = user_data.full_name,
)
return UserResponse.model_validate(user)
async def get_user_by_id(
self,
user_id: UUID,
) -> UserResponse:
"""
Get user by ID
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
return UserResponse.model_validate(user)
async def get_user_model_by_id(
self,
user_id: UUID,
) -> User:
"""
Get user model by ID (for internal use)
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
return user
async def update_user(
self,
user: User,
user_data: UserUpdate,
) -> UserResponse:
"""
Update user profile
"""
update_dict = user_data.model_dump(exclude_unset = True)
updated_user = await UserRepository.update(
self.session,
user,
**update_dict
)
return UserResponse.model_validate(updated_user)
async def change_password(
self,
user: User,
current_password: str,
new_password: str,
) -> None:
"""
Change user password
"""
is_valid, _ = await verify_password(current_password, user.hashed_password)
if not is_valid:
raise InvalidCredentials()
hashed = await hash_password(new_password)
await UserRepository.update_password(self.session, user, hashed)
async def deactivate_user(
self,
user: User,
) -> UserResponse:
"""
Deactivate user account
"""
updated = await UserRepository.update(
self.session,
user,
is_active = False
)
return UserResponse.model_validate(updated)
async def list_users(
self,
page: int,
size: int,
) -> UserListResponse:
"""
List users with pagination
"""
skip = (page - 1) * size
users = await UserRepository.get_multi(
self.session,
skip = skip,
limit = size
)
total = await UserRepository.count(self.session)
return UserListResponse(
items = [UserResponse.model_validate(u) for u in users],
total = total,
page = page,
size = size,
)
async def admin_create_user(
self,
user_data: AdminUserCreate,
) -> UserResponse:
"""
Admin creates a new user
"""
if await UserRepository.email_exists(self.session, user_data.email):
raise EmailAlreadyExists(user_data.email)
hashed = await hash_password(user_data.password)
user = await UserRepository.create(
self.session,
email = user_data.email,
hashed_password = hashed,
full_name = user_data.full_name,
role = user_data.role,
is_active = user_data.is_active,
is_verified = user_data.is_verified,
)
return UserResponse.model_validate(user)
async def admin_update_user(
self,
user_id: UUID,
user_data: UserUpdateAdmin,
) -> UserResponse:
"""
Admin updates a user
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
update_dict = user_data.model_dump(exclude_unset = True)
if "email" in update_dict:
existing = await UserRepository.get_by_email(
self.session,
update_dict["email"]
)
if existing and existing.id != user_id:
raise EmailAlreadyExists(update_dict["email"])
updated_user = await UserRepository.update(
self.session,
user,
**update_dict
)
return UserResponse.model_validate(updated_user)
async def admin_delete_user(
self,
user_id: UUID,
) -> None:
"""
Admin deletes a user (hard delete)
"""
user = await UserRepository.get_by_id(self.session, user_id)
if not user:
raise UserNotFound(str(user_id))
await UserRepository.delete(self.session, user)

View File

@ -1,284 +0,0 @@
"""
©AngelaMos | 2025
conftest.py
Test configuration, fixtures, and factories
"""
import hashlib
import secrets
from datetime import (
UTC,
datetime,
timedelta,
)
from uuid import uuid4
from collections.abc import AsyncIterator
import pytest
from httpx import (
AsyncClient,
ASGITransport,
)
import pytest_asyncio
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
)
from sqlalchemy.pool import StaticPool
from core.security import (
hash_password,
create_access_token,
)
from config import UserRole
from core.database import get_db_session
from core.Base import Base
from user.User import User
from auth.RefreshToken import RefreshToken
@pytest_asyncio.fixture(scope = "session", loop_scope = "session")
async def test_engine():
"""
Session scoped async engine with in memory SQLite
StaticPool keeps single connection so DB persists
"""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass = StaticPool,
connect_args = {"check_same_thread": False},
echo = False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def db_session(test_engine) -> AsyncIterator[AsyncSession]:
"""
Per test session with transaction rollback for isolation
App commits become savepoints that rollback with test
"""
async with test_engine.connect() as conn:
await conn.begin()
session = AsyncSession(
bind = conn,
expire_on_commit = False,
join_transaction_mode = "create_savepoint",
)
yield session
await session.close()
await conn.rollback()
@pytest.fixture
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
"""
Async HTTP client with DB session override
"""
from __main__ import app
async def override_get_db():
yield db_session
app.dependency_overrides[get_db_session] = override_get_db
async with AsyncClient(
transport = ASGITransport(app = app),
base_url = "http://test",
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
def auth_headers(access_token: str) -> dict[str, str]:
"""
Authorization headers for authenticated requests
"""
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def admin_auth_headers(admin_access_token: str) -> dict[str, str]:
"""
Authorization headers for admin requests
"""
return {"Authorization": f"Bearer {admin_access_token}"}
class UserFactory:
"""
Factory for creating test users
"""
_counter = 0
@classmethod
async def create(
cls,
session: AsyncSession,
*,
email: str | None = None,
password: str = "TestPass123",
full_name: str | None = None,
role: UserRole = UserRole.USER,
is_active: bool = True,
is_verified: bool = True,
) -> User:
cls._counter += 1
user = User(
email = email or f"user{cls._counter}@test.com",
hashed_password = await hash_password(password),
full_name = full_name or f"Test User {cls._counter}",
role = role,
is_active = is_active,
is_verified = is_verified,
)
session.add(user)
await session.flush()
await session.refresh(user)
return user
@classmethod
def reset(cls) -> None:
cls._counter = 0
class RefreshTokenFactory:
"""
Factory for creating test refresh tokens
"""
@classmethod
async def create(
cls,
session: AsyncSession,
user: User,
*,
is_revoked: bool = False,
expires_delta: timedelta = timedelta(days = 7),
) -> tuple[RefreshToken,
str]:
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token = RefreshToken(
user_id = user.id,
token_hash = token_hash,
family_id = uuid4(),
expires_at = datetime.now(UTC) + expires_delta,
is_revoked = is_revoked,
)
session.add(token)
await session.flush()
await session.refresh(token)
return token, raw_token
@pytest.fixture
async def test_user(db_session: AsyncSession) -> User:
"""
Standard test user
"""
return await UserFactory.create(db_session)
@pytest.fixture
async def admin_user(db_session: AsyncSession) -> User:
"""
Admin test user
"""
return await UserFactory.create(
db_session,
email = "admin@test.com",
role = UserRole.ADMIN,
)
@pytest.fixture
async def inactive_user(db_session: AsyncSession) -> User:
"""
Inactive test user
"""
return await UserFactory.create(
db_session,
email = "inactive@test.com",
is_active = False,
)
@pytest.fixture
def access_token(test_user: User) -> str:
"""
Valid access token for test_user
"""
return create_access_token(test_user.id, test_user.token_version)
@pytest.fixture
def admin_access_token(admin_user: User) -> str:
"""
Valid access token for admin_user
"""
return create_access_token(admin_user.id, admin_user.token_version)
@pytest.fixture
async def refresh_token_pair(
db_session: AsyncSession,
test_user: User,
) -> tuple[RefreshToken,
str]:
"""
Refresh token DB record and raw token string
"""
return await RefreshTokenFactory.create(db_session, test_user)
@pytest.fixture
async def expired_refresh_token_pair(
db_session: AsyncSession,
test_user: User,
) -> tuple[RefreshToken,
str]:
"""
Expired refresh token for testing
"""
return await RefreshTokenFactory.create(
db_session,
test_user,
expires_delta = timedelta(days = -1),
)
@pytest.fixture
async def revoked_refresh_token_pair(
db_session: AsyncSession,
test_user: User,
) -> tuple[RefreshToken,
str]:
"""
Revoked refresh token for testing
"""
return await RefreshTokenFactory.create(
db_session,
test_user,
is_revoked = True,
)
@pytest.fixture(autouse = True)
def reset_factories():
"""
Reset factory counters between tests
"""
yield
UserFactory.reset()

View File

@ -1,282 +0,0 @@
[project]
name = "full-stack-template"
version = "1.0.0"
description = "Production grade FastAPI template with async SQLAlchemy and JWT auth"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.123.0,<1.0.0",
"pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"psycopg2-binary>=2.9.11",
"sqlalchemy>=2.0.44,<3.0.0",
"alembic>=1.17.0,<2.0.0",
"asyncpg>=0.31.0,<1.0.0",
"python-multipart>=0.0.20",
"pyjwt>=2.10.0",
"pwdlib[argon2]>=0.3.0",
"uuid6>=2025.0.1",
"slowapi>=0.1.9",
"redis>=7.1.0",
"structlog>=24.4.0",
"gunicorn>=23.0.0",
"uvicorn[standard]>=0.38.0",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=6.0.0",
"httpx>=0.28.1",
"aiosqlite>=0.21.0",
"asgi-lifespan>=2.1.0",
"mypy>=1.19.0",
"types-redis>=4.6.0",
"ruff>=0.14.8",
"ty>=0.0.1a32",
"pre-commit>=4.2.0",
]
[project.urls]
Homepage = "https://XYZ.com"
Documentation = "https:/XYZ/api/docs"
Repository = "https://github.com/CarterPerez-dev/XYZ"
Issues = "https://github.com/CarterPerez-dev/XYZ/issues"
Changelog = "https://github.com/CarterPerez-dev/XYZ/blob/prod/doc/CHANGELOG.rst"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
target-version = "py312"
line-length = 88
src = ["app"]
exclude = ["alembic"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific
"ASYNC", # flake8-async
"S", # flake8-bandit (security)
"N", # pep8-naming
]
ignore = [
"E501", # line too long (formatter handles this)
"B008", # function call in default argument (FastAPI Depends)
"S101", # assert usage (needed for tests)
"S104", # 0.0.0.0 binding (intentional for Docker)
"S105", # "bearer" token_type is not a password
"ARG001", # unused function argument (common in FastAPI deps)
"E712", # == False is REQUIRED for SQLAlchemy WHERE clauses
"N999", # PascalCase module names (intentional: Base.py, User.py)
"N818", # exception naming convention (style preference)
"UP046", # Generic[T] syntax (keep for compatibility)
"RUF005", # list concatenation style (preference)
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "ARG001"]
"conftest.py" = ["S107"]
"app/core/rate_limit.py" = ["S110"]
"app/config.py" = ["F401"]
"app/**/schemas.py" = ["RUF012"]
"app/core/error_schemas.py" = ["RUF012"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
plugins = ["pydantic.mypy"]
exclude = ["alembic"]
[[tool.mypy.overrides]]
module = ["tests.*", "conftest"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["core.logging"]
disable_error_code = ["no-any-return"]
[[tool.mypy.overrides]]
module = [
"uuid6",
"structlog",
"structlog.*",
"pwdlib",
"slowapi",
"slowapi.*",
]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["config"]
implicit_reexport = true
[[tool.mypy.overrides]]
module = ["core.enums", "core.security"]
disable_error_code = ["return-value", "no-any-return"]
[[tool.mypy.overrides]]
module = ["user.repository", "auth.repository", "core.base_repository"]
disable_error_code = ["return-value", "no-any-return", "attr-defined"]
[[tool.mypy.overrides]]
module = ["user.service", "auth.service"]
disable_error_code = ["no-any-return"]
[[tool.mypy.overrides]]
module = ["factory"]
disable_error_code = ["arg-type"]
[[tool.mypy.overrides]]
module = ["auth.routes"]
disable_error_code = ["misc"]
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
[tool.pylint.main]
py-version = "3.11"
jobs = 4
load-plugins = [
"pylint_pydantic",
"pylint_per_file_ignores",
]
persistent = true
suggestion-mode = true
ignore = [
"alembic",
"venv",
".venv",
"__pycache__",
"build",
"dist",
".git",
".pytest_cache",
".mypy_cache",
".ruff_cache",
]
ignore-paths = [
"^alembic/.*",
"^venv/.*",
"^.venv/.*",
"^build/.*",
"^dist/.*",
]
[tool.pylint.messages_control]
disable = [
"C0103", # invalid-name
"C0116", # missing-function-docstring (we use minimal docs)
"C0121", # singleton-comparison (== False required for SQLAlchemy)
"C0301", # line-too-long
"C0302", # too-many-lines
"C0303", # trailing-whitespace
"C0304", # final-newline-missing
"C0305", # trailing-newlines
"C0411", # wrong-import-order
"C0412", # ungrouped-imports (style preference)
"E0401", # import-error (uuid6/structlog/pwdlib not found by pylint)
"E0611", # no-name-in-module (false positive for config re-exports)
"E1102", # not-callable (false positive for SQLAlchemy func.now/count)
"E1136", # unsubscriptable-object (false positive for generics)
"R0801", # similar-lines
"R0901", # too-many-ancestors (SQLAlchemy inheritance)
"R0903", # too-few-public-methods
"R0917", # too-many-positional-arguments (FastAPI patterns)
"W0611", # unused-import (handled by ruff, config.py re-exports)
"W0612", # unused-variable (handled by ruff)
"W0613", # unused-argument (handled by ruff)
"W0621", # redefined-outer-name (FastAPI route/param naming)
"W0622", # redefined-builtin
"W0718", # broad-exception-caught (intentional in health/rate-limit)
]
[tool.pylint-per-file-ignores]
"alembic/env.py" = "no-member"
"conftest.py" = "import-outside-toplevel"
[tool.pylint.format]
max-line-length = 95
[tool.pylint.design]
max-args = 12
max-attributes = 10
max-branches = 15
max-locals = 20
max-statements = 55
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
addopts = "-ra -q"
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.coverage.run]
branch = true
source = ["src"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
[tool.ty.src]
include = ["src", "tests"]
exclude = ["alembic/versions/**", ".venv/**"]
respect-ignore-files = true
[tool.ty.environment]
python-version = "3.12"
root = ["./src"]
python = "./.venv"
[tool.ty.rules]
possibly-missing-attribute = "error"
possibly-missing-import = "error"
unused-ignore-comment = "warn"
redundant-cast = "warn"
undefined-reveal = "warn"
[[tool.ty.overrides]]
include = ["tests/**"]
[tool.ty.overrides.rules]
unresolved-reference = "warn"
invalid-argument-type = "warn"
[[tool.ty.overrides]]
include = ["src/repositories/**", "src/services/**"]
[tool.ty.overrides.rules]
unresolved-attribute = "warn"
[tool.ty.terminal]
error-on-warning = false
output-format = "full"

View File

@ -1,4 +0,0 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -1,4 +0,0 @@
"""
AngelaMos | 2025
__init__.py
"""

View File

@ -1,288 +0,0 @@
"""
©AngelaMos | 2025
test_auth.py
"""
import pytest
from httpx import AsyncClient
from user.User import User
from auth.RefreshToken import RefreshToken
URL_LOGIN = "/v1/auth/login"
URL_REFRESH = "/v1/auth/refresh"
URL_LOGOUT = "/v1/auth/logout"
URL_LOGOUT_ALL = "/v1/auth/logout-all"
URL_ME = "/v1/auth/me"
URL_CHANGE_PASSWORD = "/v1/auth/change-password"
@pytest.mark.asyncio
async def test_login_success(client: AsyncClient, test_user: User):
"""
Valid credentials return access token and set refresh cookie
"""
response = await client.post(
URL_LOGIN,
data = {
"username": test_user.email,
"password": "TestPass123",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert "user" in data
assert data["user"]["email"] == test_user.email
assert "refresh_token" in response.cookies
@pytest.mark.asyncio
async def test_login_invalid_password(
client: AsyncClient,
test_user: User
):
"""
Wrong password returns 401
"""
response = await client.post(
URL_LOGIN,
data = {
"username": test_user.email,
"password": "WrongPassword123",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_invalid_email(client: AsyncClient):
"""
Non-existent email returns 401
"""
response = await client.post(
URL_LOGIN,
data = {
"username": "nonexistent@test.com",
"password": "TestPass123",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_inactive_user(
client: AsyncClient,
inactive_user: User
):
"""
Inactive user cannot login
"""
response = await client.post(
URL_LOGIN,
data = {
"username": inactive_user.email,
"password": "TestPass123",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token_success(
client: AsyncClient,
refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Valid refresh token returns new access token
"""
_, raw_token = refresh_token_pair
response = await client.post(
URL_REFRESH,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_refresh_token_missing_returns_401(client: AsyncClient):
"""
Missing refresh token cookie returns 401, not 422.
"""
response = await client.post(URL_REFRESH)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token_expired(
client: AsyncClient,
expired_refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Expired refresh token returns 401
"""
_, raw_token = expired_refresh_token_pair
response = await client.post(
URL_REFRESH,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token_revoked(
client: AsyncClient,
revoked_refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Revoked refresh token returns 401.
"""
_, raw_token = revoked_refresh_token_pair
response = await client.post(
URL_REFRESH,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_logout_success(
client: AsyncClient,
refresh_token_pair: tuple[RefreshToken,
str],
):
"""
Logout revokes refresh token and clears cookie.
"""
_, raw_token = refresh_token_pair
response = await client.post(
URL_LOGOUT,
cookies = {"refresh_token": raw_token},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_logout_missing_token_returns_401(client: AsyncClient):
"""
Logout without refresh token returns 401, not 422.
"""
response = await client.post(URL_LOGOUT)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_logout_all(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Logout all revokes all user sessions.
"""
response = await client.post(
URL_LOGOUT_ALL,
headers = auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "revoked_sessions" in data
@pytest.mark.asyncio
async def test_get_current_user(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
/me returns current authenticated user.
"""
response = await client.get(
URL_ME,
headers = auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user.email
assert data["id"] == str(test_user.id)
@pytest.mark.asyncio
async def test_get_current_user_unauthenticated(client: AsyncClient):
"""
/me without auth returns 401.
"""
response = await client.get(URL_ME)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_change_password(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Password change works with valid current password.
"""
response = await client.post(
URL_CHANGE_PASSWORD,
headers = auth_headers,
json = {
"current_password": "TestPass123",
"new_password": "NewTestPass456",
},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_change_password_wrong_current(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Password change fails with wrong current password.
"""
response = await client.post(
URL_CHANGE_PASSWORD,
headers = auth_headers,
json = {
"current_password": "WrongPassword123",
"new_password": "NewTestPass456",
},
)
assert response.status_code == 401

View File

@ -1,40 +0,0 @@
"""
©AngelaMos | 2025
test_health.py
"""
import pytest
from httpx import AsyncClient
URL_HEALTH = "/health"
URL_HEALTH_DETAILED = "/health/detailed"
@pytest.mark.asyncio
async def test_health_basic(client: AsyncClient):
"""
Basic health check returns 200 with healthy status
"""
response = await client.get(URL_HEALTH)
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "environment" in data
assert "version" in data
@pytest.mark.asyncio
async def test_health_detailed(client: AsyncClient):
"""
Detailed health check includes database status
"""
response = await client.get(URL_HEALTH_DETAILED)
assert response.status_code == 200
data = response.json()
assert data["status"] in ["healthy", "degraded"]
assert "database" in data
assert "environment" in data
assert "version" in data

View File

@ -1,235 +0,0 @@
"""
©AngelaMos | 2025
test_users.py
"""
import pytest
from httpx import AsyncClient
from user.User import User
URL_USERS = "/v1/admin/users"
URL_USER_ME = "/v1/users/me"
def url_user_by_id(user_id: str) -> str:
return f"{URL_USERS}/{user_id}"
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient):
"""
User registration creates new user
"""
response = await client.post(
URL_USERS,
json = {
"email": "newuser@test.com",
"password": "ValidPass123",
"full_name": "New User",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@test.com"
assert data["full_name"] == "New User"
assert "id" in data
assert "hashed_password" not in data
@pytest.mark.asyncio
async def test_create_user_duplicate_email(
client: AsyncClient,
test_user: User
):
"""
Duplicate email returns 409 conflict
"""
response = await client.post(
URL_USERS,
json = {
"email": test_user.email,
"password": "ValidPass123",
},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_create_user_weak_password(client: AsyncClient):
"""
Weak password (no uppercase/digit) returns 422
"""
response = await client.post(
URL_USERS,
json = {
"email": "weakpass@test.com",
"password": "weakpassword",
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_user_by_id(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Get user by ID returns user data
"""
response = await client.get(
url_user_by_id(str(test_user.id)),
headers = auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user.email
assert data["id"] == str(test_user.id)
@pytest.mark.asyncio
async def test_get_user_unauthenticated(
client: AsyncClient,
test_user: User
):
"""
Get user without auth returns 401
"""
response = await client.get(url_user_by_id(str(test_user.id)))
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_user_not_found(
client: AsyncClient,
auth_headers: dict[str,
str],
):
"""
Get non existent user returns 404
"""
fake_id = "00000000-0000-0000-0000-000000000000"
response = await client.get(
url_user_by_id(fake_id),
headers = auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_current_user(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Update current user profile
"""
response = await client.patch(
URL_USER_ME,
headers = auth_headers,
json = {"full_name": "Updated Name"},
)
assert response.status_code == 200
data = response.json()
assert data["full_name"] == "Updated Name"
@pytest.mark.asyncio
async def test_update_user_clear_field(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Setting field to null clears it
"""
response = await client.patch(
URL_USER_ME,
headers = auth_headers,
json = {"full_name": None},
)
assert response.status_code == 200
data = response.json()
assert data["full_name"] is None
@pytest.mark.asyncio
async def test_list_users_admin_only(
client: AsyncClient,
test_user: User,
auth_headers: dict[str,
str],
):
"""
Non admin cannot list users (403).
"""
response = await client.get(
URL_USERS,
headers = auth_headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_list_users_as_admin(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str,
str],
):
"""
Admin can list users with pagination
"""
response = await client.get(
URL_USERS,
headers = admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "size" in data
assert isinstance(data["items"], list)
@pytest.mark.asyncio
async def test_list_users_pagination(
client: AsyncClient,
admin_user: User,
admin_auth_headers: dict[str,
str],
):
"""
Pagination params work correctly
"""
response = await client.get(
URL_USERS,
headers = admin_auth_headers,
params = {
"page": 1,
"size": 5
},
)
assert response.status_code == 200
data = response.json()
assert data["page"] == 1
assert data["size"] == 5

View File

@ -1,4 +0,0 @@
"""
AngelaMos | 2025
__init__.py
"""

File diff suppressed because it is too large Load Diff

View File

@ -1,138 +0,0 @@
# =========================================
# AngelaMos | 2025
# Production | compose.yml
# =========================================
name: ${APP_NAME:-template}
services:
# Nginx + Frontend
nginx:
build:
context: .
dockerfile: infra/docker/frontend-builder.prod
args:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-My App}
container_name: ${APP_NAME:-template}-nginx
ports:
- "${NGINX_HOST_PORT:-8420}:80"
depends_on:
backend:
condition: service_healthy
networks:
- frontend
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 256M
reservations:
cpus: '0.25'
memory: 64M
restart: unless-stopped
# FastAPI backend
backend:
build:
context: ./backend
dockerfile: ../infra/docker/fastapi.prod
container_name: ${APP_NAME:-template}-backend
expose:
- "8000"
env_file:
- .env
environment:
- ENVIRONMENT=production
- DEBUG=false
- RELOAD=false
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
restart: unless-stopped
# PostgreSQL DB
db:
image: postgres:18-alpine
container_name: ${APP_NAME:-template}-db
ports:
- "${POSTGRES_HOST_PORT:-3420}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-app_db}
networks:
- backend
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app_db}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Redis
redis:
image: redis:7-alpine
container_name: ${APP_NAME:-template}-redis
ports:
- "${REDIS_HOST_PORT:-6420}:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}}
networks:
- backend
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.1'
memory: 64M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
postgres_data:
redis_data:

View File

@ -1,128 +0,0 @@
# =============================================================================
# AngelaMos | 2025
# dev.compose.yml
# =============================================================================
name: ${APP_NAME:-template}-dev
services:
# Nginx
nginx:
image: nginx:1.27-alpine
container_name: ${APP_NAME:-template}-nginx-dev
ports:
- "${NGINX_HOST_PORT:-8420}:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
networks:
- frontend
- backend
restart: unless-stopped
# FastAPI
backend:
build:
context: ./backend
dockerfile: ../infra/docker/fastapi.dev
container_name: ${APP_NAME:-template}-backend-dev
ports:
- "${BACKEND_HOST_PORT:-5420}:8000"
volumes:
- ./backend:/app
- backend_cache:/app/.venv
env_file:
- .env
environment:
- ENVIRONMENT=development
- DEBUG=true
- RELOAD=true
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Vite dev server
frontend:
build:
context: ./frontend
dockerfile: ../infra/docker/vite.dev
container_name: ${APP_NAME:-template}-frontend-dev
ports:
- "${FRONTEND_HOST_PORT:-3420}:5173"
volumes:
- ./frontend:/app
- frontend_modules:/app/node_modules
environment:
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_APP_TITLE=${VITE_APP_TITLE:-My App}
networks:
- frontend
restart: unless-stopped
# PostgreSQL DB
db:
image: postgres:16-alpine
container_name: ${APP_NAME:-template}-db-dev
ports:
- "${POSTGRES_HOST_PORT:-3420}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-app_db}
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app_db}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# Redis
redis:
image: redis:7-alpine
container_name: ${APP_NAME:-template}-redis-dev
ports:
- "${REDIS_HOST_PORT:-6420}:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
postgres_data:
redis_data:
backend_cache:
frontend_modules:

View File

@ -1,725 +0,0 @@
# Claude Max Subscription Programmatic Access Research
**Research Date:** 2025-12-14
**Focus:** Using Claude Max ($200/month) subscription programmatically in headless FastAPI backend environments
**Critical Question:** Can we use Max subscription instead of paying additional API costs?
---
## Executive Summary
**ANSWER: YES, BUT WITH SIGNIFICANT CAVEATS**
Claude Max subscriptions CAN be used programmatically in headless environments through Claude Code's OAuth authentication system. However, this approach exists in a **policy gray area** and has several production limitations:
### Key Findings:
1. **Technical Feasibility:** ✅ Possible via `CLAUDE_CODE_OAUTH_TOKEN` environment variable
2. **Official Support:** ⚠️ Limited - designed for interactive use, headless support is undocumented
3. **Terms of Service:** ⚠️ Unclear if automated usage violates ToS for consumer subscriptions
4. **Production Viability:** ⚠️ OAuth tokens expire (8-12 hours), requiring refresh mechanisms
5. **Cost Savings:** ✅ Significant - Max subscription vs. per-token API pricing
---
## Solution Summary
Claude Max subscriptions provide programmatic access through **Claude Code** using OAuth 2.0 authentication. You can authenticate in headless environments by:
1. Running `claude setup-token` to generate long-lived OAuth tokens
2. Injecting tokens via `CLAUDE_CODE_OAUTH_TOKEN` environment variable in Docker containers
3. Mounting `~/.claude/.credentials.json` as a volume for persistent authentication
4. Using the unofficial `claude_max` Python package that wraps this authentication
**However:** This approach is NOT officially documented for production server use and may violate consumer subscription terms of service. Anthropic's official position is that API usage should use the separate Anthropic API with commercial terms.
---
## Detailed Analysis
### 1. Authentication Methods for Headless Environments
#### Option A: OAuth Token Environment Variable
**How it works:**
```bash
# Generate token interactively
claude setup-token
# Export token for headless use
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-your-token-here"
# Run Claude Code programmatically
claude status
```
**Token Format:**
- Access tokens: `sk-ant-oat01-...` (expires in 8-12 hours)
- Refresh tokens: `sk-ant-ort01-...` (longer-lived, but also expires)
**Docker Usage:**
```bash
docker run --rm -it \
-e CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..." \
-v $(pwd):/app \
your-fastapi-image
```
**Sources:**
- [Setup Container Authentication - Claude Did This](https://claude-did-this.com/claude-hub/getting-started/setup-container-guide)
- [GitHub Issue #7100 - Headless Authentication Documentation](https://github.com/anthropics/claude-code/issues/7100)
- [Claude Code SDK Docker Repository](https://github.com/cabinlab/claude-code-sdk-docker)
---
#### Option B: Mount Authentication Credentials Volume
**Directory Structure:**
```
~/.claude/
├── .credentials.json # OAuth tokens (access + refresh)
├── settings.local.json # User preferences
└── [project data]
```
**Docker Compose Example:**
```yaml
services:
fastapi:
image: your-fastapi-image
volumes:
- ~/.claude:/root/.claude:ro # Mount read-only for security
- ./app:/app
```
**Advantages:**
- Automatic token refresh handled by Claude Code
- No need to manually extract tokens
- More secure than environment variables
**Disadvantages:**
- Requires initial interactive authentication
- Credentials tied to host machine
- Not suitable for cloud deployments without pre-setup
**Sources:**
- [Docker Docs - Configure Claude Code](https://docs.docker.com/ai/sandboxes/claude-code/)
- [GitHub Issue #1736 - Avoiding Re-authentication](https://github.com/anthropics/claude-code/issues/1736)
- [Medium - Running Claude Code in Docker Containers](https://medium.com/rigel-computer-com/running-claude-code-in-docker-containers-one-project-one-container-1601042bf49c)
---
#### Option C: claude_max Python Package
**What it is:**
An unofficial Python package published to PyPI (June 15, 2025) that programmatically accesses Claude Code's authentication system to use Max subscriptions for API-style completions.
**How it works:**
- Implements OAuth 2.0 with PKCE security
- Extracts authentication from Claude Code
- Provides API-compatible interface using subscription credits
**Usage Pattern:**
```python
from claude_max import ClaudeMax
# Initialize with Max subscription credentials
client = ClaudeMax()
# Make API-style calls using subscription
response = client.complete(
model="claude-opus-4-5",
messages=[{"role": "user", "content": "Hello"}]
)
```
**Critical Warning:**
> "Claude Max subscribers pay $200/month, yet there's no official way to use subscriptions for automation, with the only workaround involving fragile OAuth token extraction that may violate ToS."
**Sources:**
- [How I Built claude_max - Substack Article](https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock)
- [Maximizing Claude Max Subscription - Deeplearning.fr](https://deeplearning.fr/maximizing-your-claude-max-subscription-complete-guide-to-automated-workflows-with-claude-code-and-windsurf/)
---
### 2. Mobile Apps and Browser Wrappers Authentication
**Research Question:** How do mobile Claude apps use Max subscription if not through API?
**Finding:** Mobile apps and browser extensions use the **same OAuth 2.0 flow** as Claude Code:
1. User logs in with claude.ai credentials
2. OAuth authorization flow with PKCE
3. Receives access token (`sk-ant-oat01-...`) and refresh token (`sk-ant-ort01-...`)
4. Stores tokens locally for subsequent requests
5. Automatically refreshes when access token expires
**Key Insight:**
Mobile apps are **consumer-facing interactive applications**, which aligns with the Max subscription terms of service. A headless FastAPI backend is a **server-to-server automation**, which may NOT align with consumer subscription terms.
**Authentication Endpoint:**
```
POST https://console.anthropic.com/v1/oauth/token
{
"grant_type": "refresh_token",
"refresh_token": "sk-ant-ort01-...",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
}
```
**Sources:**
- [Claude Code Provider - Roo Code Documentation](https://docs.roocode.com/providers/claude-code)
- [GitHub - claude-token-refresh Tool](https://github.com/RavenStorm-bit/claude-token-refresh)
- [Unlock Claude API from Claude Pro/Max](https://www.alif.web.id/posts/claude-oauth-api-key)
---
### 3. Long-Lived Access Tokens from Claude Max
#### Token Lifespan
| Token Type | Prefix | Lifespan | Purpose |
|------------|--------|----------|---------|
| Access Token | `sk-ant-oat01-...` | 8-12 hours | Authenticate API requests |
| Refresh Token | `sk-ant-ort01-...` | Days to weeks | Obtain new access tokens |
| API Key | `sk-ant-api03-...` | Indefinite | Anthropic API (separate billing) |
#### `claude setup-token` Command
**Purpose:** Generate long-lived OAuth tokens for headless/CI/CD environments
**Usage:**
```bash
# Interactive setup
claude setup-token
# Output:
# "Your OAuth token: sk-ant-oat01-ABCxyz..."
# "Save this token securely - it provides full access to your account"
# Use in environment
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-ABCxyz..."
```
**Known Issues:**
- Tokens still expire after 8-12 hours
- No official documentation for production use
- Refresh token handling required for long-running services
**Bug Reports:**
> "OAuth tokens expire during long-running autonomous tasks, causing 401 authentication_error failures that require manual /login intervention."
**Sources:**
- [GitHub Issue #8938 - setup-token Not Enough to Authenticate](https://github.com/anthropics/claude-code/issues/8938)
- [GitHub Issue #12447 - OAuth Token Expiration Disrupts Workflows](https://github.com/anthropics/claude-code/issues/12447)
- [Elixir Mix Task Documentation](https://hexdocs.pm/claude_agent_sdk/Mix.Tasks.Claude.SetupToken.html)
---
### 4. Subscription Usage vs API Usage
#### How to Verify You're Using Subscription (Not API)
**Method 1: `/status` Command**
```bash
claude status
# Expected output for subscription:
# Authentication: Claude Max Subscription
# Usage: 45 of 900 messages remaining (resets in 3h 22m)
# Cost: Included in subscription
# Expected output for API:
# Authentication: API Key
# Usage: $12.45 this month
# Cost: Pay-per-token
```
**Method 2: Check Environment Variables**
```bash
# Priority order (first found wins):
# 1. ANTHROPIC_API_KEY → Uses API (costs money)
# 2. CLAUDE_CODE_OAUTH_TOKEN → Uses subscription
# 3. ~/.claude/.credentials.json → Uses subscription
# Ensure API key is NOT set:
echo $ANTHROPIC_API_KEY
# Should be empty for subscription use
```
**Method 3: Check Billing Dashboard**
Subscription usage shows as:
- **$0.00 per request** in API console
- Messages count against 5-hour rolling window
- No per-token charges
API usage shows as:
- **$X.XX per request** based on token count
- Cumulative monthly charges
- Detailed token breakdown
**Rate Limits Comparison:**
| Plan | Messages (5hr) | Prompts (5hr) | Weekly Capacity |
|------|---------------|---------------|-----------------|
| Max 5x ($100) | ~225 | 50-200 | 140-280hr Sonnet / 15-35hr Opus |
| Max 20x ($200) | ~900 | 200-800 | 240-480hr Sonnet / 24-40hr Opus |
| API | Unlimited* | Unlimited* | Based on tier/spending |
*API has separate rate limits based on tier
**Important Note:**
> "Both Pro and Max plans offer usage limits that are shared across Claude and Claude Code, meaning all activity in both tools counts against the same usage limits."
**Sources:**
- [Using Claude Code with Pro or Max Plan - Claude Help](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan)
- [About Claude's Max Plan Usage - Claude Help](https://support.claude.com/en/articles/11014257-about-claude-s-max-plan-usage)
- [GitHub Issue #1721 - Need Usage Gauge](https://github.com/anthropics/claude-code/issues/1721)
- [GitHub Issue #1287 - Misleading Cost Command Output](https://github.com/anthropics/claude-code/issues/1287)
---
### 5. Terms of Service and Policy Analysis
#### Official Anthropic Position
**Subscription vs. API Separation:**
> "A paid Claude subscription enhances your chat experience but doesn't include access to the Claude API or Console, requiring separate sign-up for API usage."
**Consumer vs. Commercial Terms:**
> "The consumer terms updates apply to users on Claude Free, Pro, and Max plans (including when they use Claude Code), but they do not apply to services under Commercial Terms, including API use."
**Key Implication:**
Max subscriptions fall under **consumer terms**, which are designed for interactive human use. Headless server automation may be considered outside the intended use case.
#### Policy Gray Area
**The Problem:**
- Claude Code technically supports headless mode
- `CLAUDE_CODE_OAUTH_TOKEN` exists for automation
- But terms of service don't explicitly permit automated usage for consumer subscriptions
**Community Concern:**
> "Claude Max subscribers pay $200/month, yet there's no official way to use subscriptions for automation... it's unclear if token extraction workarounds violate ToS. This situation undermines the value proposition of Claude Max for developers who want to integrate Claude Code into workflows."
**Feature Request (GitHub Issue #1454):**
Title: "Feature Request: Machine to Machine Authentication for Claude Max Subscriptions"
Status: Open (no official response confirming or denying legitimacy)
#### Risk Assessment for Production Use
| Risk Factor | Level | Mitigation |
|-------------|-------|------------|
| Account suspension | Medium | Use for personal projects, not enterprise |
| Token expiration | High | Implement refresh token logic |
| Policy changes | Medium | Monitor Anthropic announcements |
| Lack of support | High | No SLA for subscription-based automation |
| ToS violation | Unknown | Consult legal/Anthropic directly |
**Recommended Approach:**
1. **For personal/development:** Use Max subscription with awareness of limitations
2. **For production/enterprise:** Use official Anthropic API with commercial terms
3. **For cost optimization:** Evaluate if Max subscription ($200/month) covers your usage vs. API costs
**Sources:**
- [Feature Request #1454 - Machine to Machine Auth](https://github.com/anthropics/claude-code/issues/1454)
- [Why Pay Separately for API - Claude Help](https://support.anthropic.com/en/articles/9876003-i-have-a-paid-claude-subscription-pro-max-team-or-enterprise-plans-why-do-i-have-to-pay-separately-to-use-the-claude-api-and-console)
- [Updates to Consumer Terms - Anthropic News](https://www.anthropic.com/news/updates-to-our-consumer-terms)
- [Claude vs Claude API vs Claude Code - 16x Engineer](https://eval.16x.engineer/blog/claude-vs-claude-api-vs-claude-code)
---
## Alternative Approaches (If Subscription Headless Doesn't Work)
### Option 1: Hybrid Architecture
**Design:**
- FastAPI backend uses official Anthropic API for production
- Claude Code (Max subscription) used for development/testing only
- Separate billing but predictable costs
**Cost Structure:**
- Development: $200/month Max subscription
- Production: Pay-per-token API (budget based on usage)
---
### Option 2: WebSocket Proxy to Local Claude Code
**Architecture:**
```
FastAPI Backend (Server)
↓ WebSocket Connection
Local Claude Code Instance (Developer Machine)
↓ OAuth Authentication
Claude Max Subscription
```
**Advantages:**
- Definitely uses Max subscription
- No ToS concerns (interactive use)
**Disadvantages:**
- Not suitable for production deployment
- Requires developer machine always running
- Single point of failure
---
### Option 3: Official Enterprise Plan
**What it is:**
Enterprise plans may have different terms allowing automated usage.
**Next Steps:**
Contact Anthropic sales to inquire about:
- Enterprise API access using subscription model
- Custom rate limits
- Commercial terms for automated workflows
**Sources:**
- [Claude Pricing - Anthropic](https://claude.com/pricing)
---
## Production Implementation Guide
### If Proceeding with Max Subscription Headless (Despite Risks)
#### Step 1: Generate OAuth Tokens
```bash
# On development machine
claude setup-token
# Save output securely
# Access token: sk-ant-oat01-...
# Refresh token: sk-ant-ort01-... (from ~/.claude/.credentials.json)
```
#### Step 2: Docker Container Setup
**Dockerfile:**
```dockerfile
FROM python:3.12-slim
# Install Claude Code
RUN pip install claude-code
# Copy application
COPY ./app /app
WORKDIR /app
# Environment variable will be injected at runtime
ENV CLAUDE_CODE_OAUTH_TOKEN=""
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```
**docker-compose.yml:**
```yaml
version: '3.8'
services:
fastapi:
build: .
environment:
# CRITICAL: Do NOT set ANTHROPIC_API_KEY
# It takes precedence over OAuth token
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
volumes:
- ./app:/app
ports:
- "8000:8000"
secrets:
claude_oauth_token:
file: ./secrets/claude_oauth_token.txt
```
#### Step 3: Token Refresh Mechanism
**Python Implementation:**
```python
import httpx
import json
from pathlib import Path
class ClaudeMaxAuth:
def __init__(self):
self.credentials_path = Path.home() / ".claude" / ".credentials.json"
self.access_token = None
self.refresh_token = None
self._load_credentials()
def _load_credentials(self):
"""Load tokens from credentials file or environment"""
if self.credentials_path.exists():
with open(self.credentials_path) as f:
creds = json.load(f)
oauth = creds.get("claudeAiOauth", {})
self.access_token = oauth.get("accessToken")
self.refresh_token = oauth.get("refreshToken")
async def refresh_access_token(self):
"""Refresh expired access token"""
async with httpx.AsyncClient() as client:
response = await client.post(
"https://console.anthropic.com/v1/oauth/token",
json={
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
}
)
response.raise_for_status()
data = response.json()
self.access_token = data["access_token"]
# Update credentials file
self._save_credentials()
def _save_credentials(self):
"""Save updated tokens back to credentials file"""
# Implementation details...
pass
```
**Usage in FastAPI:**
```python
from fastapi import FastAPI, Depends
from claude_max import ClaudeMaxAuth
app = FastAPI()
auth = ClaudeMaxAuth()
async def get_claude_client():
"""Dependency that ensures fresh tokens"""
# Check if token needs refresh (implement logic)
if auth.token_expired():
await auth.refresh_access_token()
return auth
@app.post("/api/chat")
async def chat(
request: ChatRequest,
claude: ClaudeMaxAuth = Depends(get_claude_client)
):
# Use claude.access_token for requests
pass
```
#### Step 4: Monitoring and Fallback
**Monitor subscription usage:**
```python
import subprocess
def check_subscription_status():
"""Check Claude Code subscription status"""
result = subprocess.run(
["claude", "status"],
capture_output=True,
text=True
)
# Parse output to check remaining quota
return result.stdout
```
**Implement fallback to API:**
```python
async def make_claude_request(prompt: str):
"""Try subscription first, fallback to API"""
try:
# Try subscription
response = await request_via_subscription(prompt)
return response
except QuotaExceededError:
# Fallback to API
logging.warning("Subscription quota exceeded, using API")
return await request_via_api(prompt)
```
---
## Critical Production Considerations
### 1. Token Expiration Handling
**Problem:**
Access tokens expire every 8-12 hours, causing service interruptions.
**Solutions:**
- Implement automatic refresh before expiration
- Use refresh token rotation
- Monitor token validity and proactively refresh
- Have API key fallback for emergencies
### 2. Rate Limit Management
**Max Subscription Limits:**
- 900 messages / 5 hours (Max 20x plan)
- 200-800 prompts / 5 hours for Claude Code
**Strategies:**
- Implement request queuing
- Track usage against 5-hour rolling window
- Return 429 errors when approaching limit
- Cache responses to reduce requests
### 3. Shared Quota Between Web and Code
**Critical Issue:**
> "Usage limits are shared between Claude Code and web claude.ai usage"
**Implications:**
- If you use claude.ai in browser, it reduces FastAPI quota
- No way to reserve capacity for backend only
- Unpredictable availability during high web usage
**Mitigation:**
- Use separate Claude account for backend
- Monitor total usage across all channels
- Set up alerts for high usage
### 4. No Service Level Agreement (SLA)
**Risk:**
- No guaranteed uptime for subscription-based access
- No support for programmatic usage issues
- Changes can break implementation without notice
**Mitigation:**
- Don't use for mission-critical services
- Always have API fallback
- Monitor Anthropic announcements
---
## Cost-Benefit Analysis
### Scenario 1: Light Usage (< $200/month API cost)
**Recommendation:** Use Anthropic API directly
**Reasoning:**
- Simpler implementation
- Official support
- Commercial terms
- Predictable costs
### Scenario 2: Heavy Usage ($200-$1000/month API cost)
**Recommendation:** Max subscription for development, API for production
**Reasoning:**
- Max subscription saves development costs
- API provides production reliability
- Total cost still lower than pure API
- Clear separation of concerns
### Scenario 3: Very Heavy Usage (> $1000/month API cost)
**Recommendation:** Contact Anthropic for Enterprise plan
**Reasoning:**
- Custom pricing available
- Potentially subscription-style billing for automation
- Dedicated support
- SLA guarantees
---
## Final Recommendations
### ✅ Use Max Subscription Headless If:
- Personal project or internal tool
- Comfortable with policy gray area
- Can handle occasional service disruptions
- Have technical ability to implement token refresh
- Usage fits within Max limits ($200/month tier)
### ❌ Do NOT Use Max Subscription Headless If:
- Production customer-facing service
- Enterprise/commercial application
- Need SLA guarantees
- Usage exceeds Max limits
- Uncomfortable with potential ToS violations
### ✅ Recommended Approach:
1. **Development:** Use Max subscription ($200/month)
2. **Staging:** Use Max subscription with monitoring
3. **Production:** Use official Anthropic API with commercial terms
4. **Cost Optimization:** Evaluate usage patterns after 1 month
---
## Authoritative Sources Summary
### Official Documentation:
- [Using Claude Code with Pro or Max - Claude Help](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan)
- [Docker Configure Claude Code](https://docs.docker.com/ai/sandboxes/claude-code/)
- [Claude Code Development Containers](https://code.claude.com/docs/en/devcontainer)
### Community Resources:
- [GitHub - claude-code-sdk-docker](https://github.com/cabinlab/claude-code-sdk-docker)
- [Setup Container Authentication Guide](https://claude-did-this.com/claude-hub/getting-started/setup-container-guide)
- [GitHub - claude-token-refresh Tool](https://github.com/RavenStorm-bit/claude-token-refresh)
### Technical Analysis:
- [How I Built claude_max - Substack](https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock)
- [Claude vs Claude API vs Claude Code](https://eval.16x.engineer/blog/claude-vs-claude-api-vs-claude-code)
### GitHub Issues (Feature Requests & Bugs):
- [Issue #1454 - Machine to Machine Auth for Max](https://github.com/anthropics/claude-code/issues/1454)
- [Issue #7100 - Document Headless Authentication](https://github.com/anthropics/claude-code/issues/7100)
- [Issue #12447 - OAuth Token Expiration](https://github.com/anthropics/claude-code/issues/12447)
- [Issue #8938 - setup-token Not Enough](https://github.com/anthropics/claude-code/issues/8938)
---
## Open Questions (Require Official Anthropic Response)
1. **Is programmatic use of Max subscriptions permitted under consumer ToS?**
- Status: Unclear
- Action: Submit support ticket to Anthropic
2. **Will Max subscriptions ever support official headless/server authentication?**
- Status: Feature request open (Issue #1454)
- Action: Monitor GitHub issues
3. **What is the intended use case for `claude setup-token` command?**
- Status: Undocumented
- Action: Request official documentation
4. **Are there Enterprise plans with subscription-style pricing for automation?**
- Status: Unknown
- Action: Contact Anthropic sales
---
## Conclusion
**YES, you CAN use Claude Max subscription programmatically in headless FastAPI backends** through OAuth token authentication, but this approach:
1. ✅ **Works technically** - Multiple methods available
2. ⚠️ **Exists in policy gray area** - ToS unclear on automated usage
3. ⚠️ **Requires token refresh implementation** - Not zero-maintenance
4. ⚠️ **Has production limitations** - No SLA, shared quotas, expiring tokens
5. ✅ **Saves significant costs** - $200/month vs potentially thousands in API fees
**Recommended path forward:**
1. Prototype with Max subscription to prove concept
2. Measure actual usage patterns
3. Calculate API costs for production scale
4. **If costs < $200/month:** Switch to official API
5. **If costs > $200/month:** Continue with Max but implement robust fallback
6. **If costs >> $1000/month:** Contact Anthropic for Enterprise pricing
**Critical action item:** Submit support ticket to Anthropic asking explicitly if programmatic use of Max subscriptions for headless server environments is permitted under current ToS.
---
**Research Completed:** 2025-12-14
**Last Updated:** 2025-12-14
**Next Review:** Monitor GitHub issues and Anthropic announcements monthly

View File

@ -1,507 +0,0 @@
# Production Docker stack for FastAPI and React in 2025
The modern Docker Compose ecosystem has matured significantly, with **the Compose Specification replacing legacy v2/v3 syntax** and BuildKit becoming the default engine. Your proposed architecture is sound, but several optimizations can dramatically improve performance, security, and developer experience. The key shifts for 2025: adopt **uv** as your Python package manager (10-100× faster than pip), consider **Granian** as an alternative ASGI server for maximum throughput, leverage **Compose Watch** for superior hot-reload without bind mounts, and structure your Dockerfiles with multi-stage builds using BuildKit cache mounts.
This guide provides senior-level patterns addressing your specific stack: FastAPI with async SQLAlchemy, React/Vite frontend, Nginx reverse proxy with WebSocket support, and proper dev/prod separation.
## Modern Compose architecture eliminates version confusion
The **`version` field is now deprecated and should be omitted** entirely. Since Compose v1.27.0+, the unified Compose Specification auto-detects behavior. Your file naming convention is correct—prefer `compose.yml` over the legacy `docker-compose.yml`.
For your proposed structure, the recommended override pattern maximizes code reuse while maintaining clear separation:
```yaml
# compose.yml (Base/shared configuration - no version field)
name: myproject
services:
api:
build:
context: ./backend
dockerfile: ../conf/docker/fastapi.Dockerfile
target: ${BUILD_TARGET:-production}
networks:
- backend
- frontend
depends_on:
db:
condition: service_healthy
restart: true # Compose 2.17.0+ restarts api if db restarts
frontend:
build:
context: ./frontend
dockerfile: ../conf/docker/frontend.Dockerfile
target: ${BUILD_TARGET:-production}
networks:
- frontend
nginx:
image: nginx:1.27-alpine
volumes:
- ./conf/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf/nginx/${NGINX_CONFIG:-prod}.nginx:/etc/nginx/conf.d/default.conf:ro
ports:
- "80:80"
depends_on:
api:
condition: service_healthy
networks:
- frontend
- backend
db:
image: postgres:16-alpine
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- backend
networks:
backend:
frontend:
volumes:
db_data:
```
Custom networks are essential for security—your database sits only on the `backend` network, inaccessible to the frontend container. Services resolve each other by name automatically (`http://api:8000`).
**Compose Watch** (GA in Compose 2.22.0+) provides superior hot-reload compared to traditional bind mounts, with granular control over sync, rebuild, and restart actions. For development, your override file would include:
```yaml
# compose.dev.yml
services:
api:
build:
target: development
develop:
watch:
- action: sync
path: ./backend
target: /app
ignore:
- __pycache__/
- .venv/
- action: rebuild
path: ./backend/pyproject.toml
ports:
- "8000:8000"
environment:
- NGINX_CONFIG=dev
frontend:
build:
target: development
develop:
watch:
- action: sync
path: ./frontend/src
target: /app/src
- action: rebuild
path: ./frontend/package.json
```
Execute with `docker compose watch` for development. For production: `docker compose -f compose.yml up -d`.
**Resource limits now work without Swarm** using the `deploy.resources` syntax. Always set these in production to prevent runaway containers:
```yaml
services:
api:
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
```
## FastAPI containers need uv, multi-stage builds, and careful ASGI selection
**Use `python:3.12-slim` as your base image.** Alpine's musl libc causes package compatibility issues and can make builds 50× slower when compiling native extensions. The slim variant offers the best balance at ~130MB.
**uv is now production-ready** with 16+ million monthly downloads. Created by Astral (the Ruff team), it provides 10-100× faster dependency installation with proper lock file support. Here's the complete production Dockerfile pattern:
```dockerfile
# conf/docker/fastapi.Dockerfile
# syntax=docker/dockerfile:1
# ============ BUILD STAGE ============
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
# Install dependencies first (cached layer)
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project --no-dev
# Install project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-editable
# ============ DEVELOPMENT STAGE ============
FROM python:3.12-slim AS development
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /bin/
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen
COPY . .
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# ============ PRODUCTION STAGE ============
FROM python:3.12-slim AS production
# Security: non-root user
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m -s /bin/false appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appgroup /app /app
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
```
The `--mount=type=cache` directive is a BuildKit feature that caches pip/uv downloads between builds—essential for CI/CD speed. The `UV_COMPILE_BYTECODE=1` flag pre-compiles Python files for faster startup in production.
**ASGI server selection in 2025** presents interesting choices. Uvicorn remains the safe default, but **Granian** (Rust-based) delivers higher throughput with more consistent latency:
| Server | Requests/sec | Latency Gap (avg/max) | Best For |
|--------|-------------|----------------------|----------|
| Granian | ~50,000 | 2.8× | Maximum performance |
| Uvicorn (httptools) | ~45,000 | 6.8× | General production |
| Gunicorn + Uvicorn | ~45,000 | 6.5× | Process management |
| Hypercorn | ~35,000 | 5.2× | HTTP/2, HTTP/3 |
For Uvicorn with multiple workers: `uvicorn app.main:app --workers 4`. The worker formula for async I/O-bound apps is `(2 × CPU_COUNT) + 1`. For Gunicorn with process management benefits, use `gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --max-requests 1000 --max-requests-jitter 100`.
**Async SQLAlchemy connection pooling** requires attention in containers:
```python
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(
"postgresql+asyncpg://user:pass@db:5432/dbname",
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # Verify connections before use
pool_recycle=3600, # Recycle after 1 hour
)
```
## Frontend builds require HMR fixes and strategic caching
Vite in Docker requires specific configuration to enable Hot Module Replacement through container networking. Your `vite.config.ts` must bind to all interfaces and enable polling for file system events:
```typescript
export default defineConfig({
server: {
host: "0.0.0.0",
port: 5173,
watch: { usePolling: true },
hmr: { clientPort: 5173 },
},
});
```
**Use `node:22-slim` for production builds**—it provides glibc compatibility and lower CVE counts than Alpine. The multi-stage frontend Dockerfile should separate dependency installation from build for optimal caching:
```dockerfile
# conf/docker/frontend.Dockerfile
# ========== DEVELOPMENT ==========
FROM node:22-slim AS development
WORKDIR /app
COPY package*.json ./
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev"]
# ========== BUILD STAGE ==========
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
ENV NODE_ENV=production
RUN npm ci --only=production
COPY . .
RUN npm run build
# ========== PRODUCTION ==========
FROM nginx:1.27-alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
RUN chown -R nginx:nginx /usr/share/nginx/html
USER nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
The **node_modules handling** question has a definitive answer for Docker: install in the container, not on the host. Use an anonymous volume to preserve container modules when bind-mounting source:
```yaml
volumes:
- ./frontend:/app
- /app/node_modules # Preserves container's node_modules
```
**pnpm is the 2025 recommendation** for new projects—it provides significant disk space savings through a shared store and faster installs. Enable it in your Dockerfile with `RUN corepack enable && corepack prepare pnpm@latest --activate`.
**Vite environment variables are statically replaced at build time**—they become hardcoded strings. For runtime configuration, use the placeholder pattern:
```dockerfile
ENV VITE_API_URL="__VITE_API_URL__"
RUN npm run build
CMD ["/bin/sh", "-c", \
"find /usr/share/nginx/html -type f -name '*.js' -exec sed -i 's|__VITE_API_URL__|'$VITE_API_URL'|g' {} + && \
nginx -g 'daemon off;'"]
```
## Nginx configuration balances performance, security, and WebSocket support
Your nginx.conf should establish global settings while environment-specific server blocks handle routing differences. The critical pattern for FastAPI reverse proxying includes **upstream keepalive for connection pooling** and proper header forwarding:
```nginx
# conf/nginx/nginx.conf
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
server_tokens off;
# Compression
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
limit_req_status 429;
# WebSocket upgrade map
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Upstream with connection pooling
upstream fastapi {
server api:8000;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
include /etc/nginx/conf.d/*.conf;
}
```
The production server block handles API proxying, WebSocket connections, and static file serving with proper cache headers:
```nginx
# conf/nginx/prod.nginx
server {
listen 80;
root /usr/share/nginx/html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# API proxy with rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://fastapi/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 32k;
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket endpoint
location /api/ws {
proxy_pass http://fastapi;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
proxy_buffering off;
}
# Hashed assets - cache forever
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# SPA fallback - never cache index.html
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}
```
**WebSocket configuration requires specific attention**: the `Upgrade` and `Connection` headers are hop-by-hop and must be explicitly forwarded. The `map` directive dynamically sets the Connection header based on whether an upgrade is requested. The **86400-second timeout** prevents Nginx from closing idle WebSocket connections.
**Keep proxy_buffering ON for regular API requests**—this protects FastAPI from slow clients by letting Nginx accept the full response and free the uvicorn worker immediately. Only disable buffering for WebSocket and Server-Sent Events endpoints.
For development, your dev.nginx proxies to the Vite dev server instead of serving static files:
```nginx
# conf/nginx/dev.nginx
server {
listen 80;
access_log /var/log/nginx/access.log detailed;
add_header Cache-Control "no-store" always;
location / {
proxy_pass http://frontend:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
## Security hardening requires layered defenses
Container security follows defense-in-depth principles. **Never run containers as root**—this is the single most important security measure. Combine with capability dropping and read-only filesystems:
```yaml
services:
api:
user: "1001:1001"
read_only: true
tmpfs:
- /tmp
- /var/run
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
```
**Docker secrets provide secure credential handling** without exposing values in environment variables or compose files:
```yaml
services:
db:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # Git-ignored
```
Your FastAPI app reads secrets from the mounted file:
```python
def get_secret(name: str) -> str:
secret_path = Path(f"/run/secrets/{name}")
if secret_path.exists():
return secret_path.read_text().strip()
return os.environ.get(name.upper(), "")
```
**Handle CORS at the FastAPI level**, not Nginx—this allows dynamic origin handling and proper credential support. Never configure CORS in both layers simultaneously.
For zero-downtime deployments with Docker Compose, the **docker-rollout** plugin provides seamless rolling updates:
```bash
curl https://raw.githubusercontent.com/wowu/docker-rollout/main/docker-rollout \
-o ~/.docker/cli-plugins/docker-rollout
chmod +x ~/.docker/cli-plugins/docker-rollout
docker rollout api # Instead of docker compose up -d
```
**Health checks are mandatory for reliable orchestration**. Implement both liveness (is the process running?) and readiness (can we serve traffic?) endpoints:
```python
@router.get("/health")
async def liveness():
return {"status": "alive"}
@router.get("/health/ready")
async def readiness(db: AsyncSession = Depends(get_db)):
await db.execute(text("SELECT 1"))
return {"status": "ready", "database": "connected"}
```
Database migrations should be decoupled from application startup in production. Run them as a separate step: `docker compose run --rm api alembic upgrade head`, then deploy with `docker compose up -d`.
## Conclusion
The 2025 Docker ecosystem offers substantial improvements over previous patterns. The unified Compose Specification eliminates version confusion, BuildKit cache mounts dramatically accelerate CI/CD builds, and tools like uv transform Python dependency management. Your architecture is well-structured—the key refinements are adopting multi-stage Dockerfiles with explicit development/production targets, leveraging Compose Watch for superior hot-reload, and ensuring proper security hardening with non-root users, capability dropping, and secrets management.
For maximum performance, consider Granian as your ASGI server—its Rust-based implementation delivers 10-15% higher throughput with more consistent latency than Uvicorn. The upstream keepalive configuration in Nginx is often overlooked but critical for reducing TCP handshake overhead between your reverse proxy and FastAPI workers.
The anti-patterns to actively avoid: bind-mounting code in production, using the `version` field in compose files, running containers as root, hardcoding secrets anywhere, and configuring CORS in both Nginx and FastAPI simultaneously. These patterns cause subtle production issues that are difficult to debug.

View File

@ -1,492 +0,0 @@
SQLAlchemy Enums - Careful what goes into the database
Will Rouesnel 2024-04-25 21:54
The Situation
SQLAlchemy is an obvious choice when you need to throw together anything dealing with databases in Python. There might be other options, there might be faster options, but if you need it done then SQLAlchemy will do it for you pretty well and very ergonomically.
The problem I ran into recently is dealing with Python enums recently. Or more specifically: I had a user input problem which obviously turned into an enum application side - I had a limited set of inputs I wanted to allow, because those were what we supported - and I didn't want strings all through my code testing for values.
So on the client side it's obvious: check if the string matches an enum value, and use that. The enum would look something like below:
from enum import Enum
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
Now from this, we have our second problem: storing this in the database. We want to not do work here - that's we're using SQLAlchemy, so we can have our commmon problems handled. And so, SQLAlchemy helps us - here's automatic enum type handling for us.
Easy - so our model using the declarative syntax, and typehints can be written as follows:
import sqlalchemy
from sqlalchemy.orm import Mapped, DeclarativeBase, Session, mapped_column
from sqlalchemy import create_engine, select, text
class Base(DeclarativeBase):
pass
class TestTable(Base):
__tablename__ = "test_table"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Color]
This is essentially identical to the documentation we see above. And, if we run this in a sample program - it works!
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as session:
# Create normal values
for enum_item in Color:
session.add(TestTable(value=enum_item))
session.commit()
# Now try and read the values back
with Session(engine) as session:
records = session.scalars(select(TestTable)).all()
for record in records:
print(record.value)
Color.RED
Color.GREEN
Color.BLUE
Right? We stored some enum's to the database and retreived them in simple elegant code. This is exactly what we want...right?
But the question is...what did we actually store? Let's extend the program to do a raw query to read back that table...
from sqlalchemy import text
with engine.connect() as conn:
print(conn.execute(text("SELECT * FROM test_table;")).all())
[(1, 'RED'), (2, 'GREEN'), (3, 'BLUE')]
Notice the tuples: the second column, we see "RED", "GREEN" and "BLUE"...but our enum defines our colors as RED is "red". What's going on? And is something wrong here?
Depending how you view the situation, yes, but also no - but it's likely this isn't what you wanted either.
The primary reason to use SQLAlchemy enum types is to take advantage of something like PostgreSQL supporting native enum types in the database. Everywhere else in SQLAlchemy, when we define a python class - like we do with TestTable above - we're not defining a Python object, we're defining a Python object which is describing the database objects we want and how they'll behave.
And so long as we're using things that come from SQLAlchemy - and under-the-hood SQLAlchemy is converting that enum.Enum to sqlalchemy.Enum - then this makes complete sense. The enum we declare is declaring what values we store, and what data value they map too...in the sense that we might use the data elsewhere, in our application. Basically our database will hold the symbolic value RED and we interpret that as meaning "red" - but we reserve the right to change that interpretation.
But if we're coming at this from a Python application perspective - i.e. the reason we made an enum - we likely have a different view of the problem. We're thinking "we want the data to look a particular way, and then to refer to it symbolically in code which we might change" - i.e. the immutable element is the data, the value, of the enum - because that's what we'll present to the user, but not what we want to have all over the application.
In isolation these are separate problems, but automatic enum handling makes the boundary here fuzzy: because while the database is defined in our code, from one perspective, it's also external to it - i.e. we may be writing code which is meant to simply interface with and understand a database not under our control. Basically, the enum.Enum object feels like it's us saying "this is how we'll interpret the external world" and not us saying "this is what the database looks like".
And in that case then, our view of what the enum is is probably more like "the enum is the internal symbolic representation of how we plan to consume database values" - i.e. we expect to map "red" to Color.RED from the database. Rather then reading the database and interpreting RED as "red".
Nobodies wrong - but you probably have your assumptions going into this (I know I did...but it compiled, it worked, and I never questioned it - and so long as I'm the sole owner, who cares right?)
The Problem
There are a few problems though with this interpretation. One is obvious: we're a simple, apparently safe refactor away from ruining our database schema and we might be aware of it. In the above, naive interpretation, changing Color.RED to Color.LEGACY_RED for example, is implying that RED is no longer a valid value in the database - which if we think of the enum as an application mapping to an external interface is something which might make sense.
This is the sort of change which crops up all the time. We know the string "red" is out there, hardcoded and compiled into a bunch of old systems so we can't just go and change a color name in the database. Or we're doing rolling deployments and we need consistency of values - or share the database or any number of other complex environment concerns. Either way: we want to avoid needlessly updating the database value - changing our code, but not an apparent variable constant - should be safe.
However we're not storing the data we think we are. We expected "red", "green" and "blue" and got "RED", "GREEN" and "BLUE". It's worth noting that the SQLAlchemy documentation leads you astray like this, since the second example showing using typing.Literal for the mapping uses the string assignments from the first (and neither shows a sample table result which makes it obvious on a quick read).
If we change a name in this enum, then the result is actually bad if we've used it anywhere - we stop being able to read models out of this table at all. So if we do the following:
class Color(Enum):
LEGACY_RED = "red"
GREEN = "green"
BLUE = "blue"
Then try to read the models we've created, it won't work - in fact we can't read any part of that table anymore (this post is written as a Jupyter notebook so the redefinition below is needed to setup the SQLAlchemy model again)
class Base(DeclarativeBase):
pass
class TestTable(Base):
__tablename__ = "test_table"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Color]
with Session(engine) as session:
records = session.scalars(select(TestTable)).all()
for record in records:
print(record.value)
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
~/.local/lib/python3.10/site-packages/sqlalchemy/sql/sqltypes.py in _object_value_for_elem(self, elem)
1608 try:
-> 1609 return self._object_lookup[elem]
1610 except KeyError as err:
KeyError: 'RED'
The above exception was the direct cause of the following exception:
LookupError Traceback (most recent call last)
/tmp/ipykernel_69447/1820198460.py in <module>
8
9 with Session(engine) as session:
---> 10 records = session.scalars(select(TestTable)).all()
11 for record in records:
12 print(record.value)
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in all(self)
1767
1768 """
-> 1769 return self._allrows()
1770
1771 def __iter__(self) -> Iterator[_R]:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _allrows(self)
546 make_row = self._row_getter
547
--> 548 rows = self._fetchall_impl()
549 made_rows: List[_InterimRowType[_R]]
550 if make_row:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _fetchall_impl(self)
1674
1675 def _fetchall_impl(self) -> List[_InterimRowType[Row[Any]]]:
-> 1676 return self._real_result._fetchall_impl()
1677
1678 def _fetchmany_impl(
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _fetchall_impl(self)
2268 self._raise_hard_closed()
2269 try:
-> 2270 return list(self.iterator)
2271 finally:
2272 self._soft_close()
~/.local/lib/python3.10/site-packages/sqlalchemy/orm/loading.py in chunks(size)
217 break
218 else:
--> 219 fetch = cursor._raw_all_rows()
220
221 if single_entity:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _raw_all_rows(self)
539 assert make_row is not None
540 rows = self._fetchall_impl()
--> 541 return [make_row(row) for row in rows]
542
543 def _allrows(self) -> List[_R]:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in <listcomp>(.0)
539 assert make_row is not None
540 rows = self._fetchall_impl()
--> 541 return [make_row(row) for row in rows]
542
543 def _allrows(self) -> List[_R]:
lib/sqlalchemy/cyextension/resultproxy.pyx in sqlalchemy.cyextension.resultproxy.BaseRow.__init__()
lib/sqlalchemy/cyextension/resultproxy.pyx in sqlalchemy.cyextension.resultproxy._apply_processors()
~/.local/lib/python3.10/site-packages/sqlalchemy/sql/sqltypes.py in process(value)
1727 value = parent_processor(value)
1728
-> 1729 value = self._object_value_for_elem(value)
1730 return value
1731
~/.local/lib/python3.10/site-packages/sqlalchemy/sql/sqltypes.py in _object_value_for_elem(self, elem)
1609 return self._object_lookup[elem]
1610 except KeyError as err:
-> 1611 raise LookupError(
1612 "'%s' is not among the defined enum values. "
1613 "Enum name: %s. Possible values: %s"
LookupError: 'RED' is not among the defined enum values. Enum name: color. Possible values: LEGACY_RED, GREEN, BLUE
Even though we did a proper refactor, we can no longer read this table - in fact we can't even read part of it without using raw SQL and giving up on our models entirely. Obviously if we were writing an application, we've just broken all our queries - but not because we messed anything up, but because we thought we were making a code change when in reality we were making a data change.
This behavior also makes it pretty much impossible to handle externally managed schemas or existing schemas - we don't really want our enum to have to follow someone else's data scheme, even if they're well behaved.
Finally it also hightlights another danger we've walked into: what if we try to read this column, and there are values there we don't recognize? We would also get the same error - in this case, RED is unknown because we removed it. But if a new version of our application comes along and has inserted ORANGE then we'd also have the same problem - we've lost backwards and forwards compatibility, in a way which doesn't necessarily show up easily. There's just no easy way to deal with these LookupError validation problems when we're loading large chunks of models - they happen at the wrong part of the stack
The Solution
Doing the obvious thing here got us a working applications with a bunch of technical footguns - which is unfortunate, but it does work. There are plenty of situations where we'd never encounter these though - although many more where we might. So what should we do instead?
To get the behavior we expected when we used an enum we can do the following in our model definition:
class Base(DeclarativeBase):
pass
class TestTable(Base):
__tablename__ = "test_table"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Color] = mapped_column(sqlalchemy.Enum(Color, values_callable=lambda t: [ str(item.value) for item in t ]))
Notice the values_callable parameter. The order returned here should be the order our enum returns (and it is - it's simply passed our Enum object) - and returns the list of values which should be assigned in the database for it. In this case we simply do a Python string conversion of the enum value (which will just return the literal string - but if you were doing something ill-advised like mixing in numbers, then this makes it sensible for the DB).
When we run this with a new database, we now see that we get what we expected in the underlying table:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as session:
# Create normal values
for enum_item in Color:
session.add(TestTable(value=enum_item))
session.commit()
# Now try and read the values back
with Session(engine) as session:
records = session.scalars(select(TestTable)).all()
print("We restored the following values in code...")
for record in records:
print(record.value)
print("But the underlying table contains...")
with engine.connect() as conn:
print(conn.execute(text("SELECT * FROM test_table;")).all())
We restored the following values in code...
Color.LEGACY_RED
Color.GREEN
Color.BLUE
But the underlying table contains...
[(1, 'red'), (2, 'green'), (3, 'blue')]
Perfect. Now if we're connecting to an external database, or a schema we don't control, everything works great. But what about when we have unknown values? What happens then? Well we haven't fixed that, but we're much less likely to encounter it by accident now. Of course it's worth noting, SQLAlchemy also doesn't validate the inputs we put into this model against the enum before we write it either. So if we do this, then we're back to it not working:
with Session(engine) as session:
session.add(TestTable(value="reed"))
session.commit()
# Now try and read the values back
with Session(engine) as session:
records = session.scalars(select(TestTable)).all()
print("We restored the following values in code...")
for record in records:
print(record.value)
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
~/.local/lib/python3.10/site-packages/sqlalchemy/sql/sqltypes.py in _object_value_for_elem(self, elem)
1608 try:
-> 1609 return self._object_lookup[elem]
1610 except KeyError as err:
KeyError: 'reed'
The above exception was the direct cause of the following exception:
LookupError Traceback (most recent call last)
/tmp/ipykernel_69447/3460624042.py in <module>
1 # Now try and read the values back
2 with Session(engine) as session:
----> 3 records = session.scalars(select(TestTable)).all()
4 print("We restored the following values in code...")
5 for record in records:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in all(self)
1767
1768 """
-> 1769 return self._allrows()
1770
1771 def __iter__(self) -> Iterator[_R]:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _allrows(self)
546 make_row = self._row_getter
547
--> 548 rows = self._fetchall_impl()
549 made_rows: List[_InterimRowType[_R]]
550 if make_row:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _fetchall_impl(self)
1674
1675 def _fetchall_impl(self) -> List[_InterimRowType[Row[Any]]]:
-> 1676 return self._real_result._fetchall_impl()
1677
1678 def _fetchmany_impl(
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _fetchall_impl(self)
2268 self._raise_hard_closed()
2269 try:
-> 2270 return list(self.iterator)
2271 finally:
2272 self._soft_close()
~/.local/lib/python3.10/site-packages/sqlalchemy/orm/loading.py in chunks(size)
217 break
218 else:
--> 219 fetch = cursor._raw_all_rows()
220
221 if single_entity:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in _raw_all_rows(self)
539 assert make_row is not None
540 rows = self._fetchall_impl()
--> 541 return [make_row(row) for row in rows]
542
543 def _allrows(self) -> List[_R]:
~/.local/lib/python3.10/site-packages/sqlalchemy/engine/result.py in <listcomp>(.0)
539 assert make_row is not None
540 rows = self._fetchall_impl()
--> 541 return [make_row(row) for row in rows]
542
543 def _allrows(self) -> List[_R]:
lib/sqlalchemy/cyextension/resultproxy.pyx in sqlalchemy.cyextension.resultproxy.BaseRow.__init__()
lib/sqlalchemy/cyextension/resultproxy.pyx in sqlalchemy.cyextension.resultproxy._apply_processors()
~/.local/lib/python3.10/site-packages/sqlalchemy/sql/sqltypes.py in process(value)
1727 value = parent_processor(value)
1728
-> 1729 value = self._object_value_for_elem(value)
1730 return value
1731
~/.local/lib/python3.10/site-packages/sqlalchemy/sql/sqltypes.py in _object_value_for_elem(self, elem)
1609 return self._object_lookup[elem]
1610 except KeyError as err:
-> 1611 raise LookupError(
1612 "'%s' is not among the defined enum values. "
1613 "Enum name: %s. Possible values: %s"
LookupError: 'reed' is not among the defined enum values. Enum name: color. Possible values: red, green, blue
Broken again.
So how do we fix this?
Handling Unknown Values
All the cases we've seen of LookupErrors are essentially a problem that we have no unknown value handler - ultimately in all applications where the value could change - which I would argue should always be considered to be all of them - we in fact should have had an option which specified handling an unknown one.
At this point we need to subclass the SQLAlchemy Enum type, and specify that directly - which do like so:
import typing as t
class EnumWithUnknown(sqlalchemy.Enum):
def __init__(self, *enums, **kw: t.Any):
super().__init__(*enums, **kw)
# SQLAlchemy sets the _adapted_from keyword argument sometimes, which contains a reference to the original type - but won't include
# original keyword arguments, so we need to handle that here.
self._unknown_value = kw["_adapted_from"]._unknown_value if "_adapted_from" in kw else kw.get("unknown_value",None)
if self._unknown_value is None:
raise ValueError("unknown_value should be a member of the enum")
# This is the function which resolves the object for the DB value
def _object_value_for_elem(self, elem):
try:
return self._object_lookup[elem]
except LookupError:
return self._unknown_value
And then we can use this type like follows:
class Color(Enum):
UNKNOWN = "unknown"
LEGACY_RED = "red"
GREEN = "green"
BLUE = "blue"
class Base(DeclarativeBase):
pass
class TestTable(Base):
__tablename__ = "test_table"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Color] = mapped_column(EnumWithUnknown(Color, values_callable=lambda t: [ str(item.value) for item in t ],
unknown_value=Color.UNKNOWN))
Let's run that against the database we just inserted reed into:
# Now try and read the values back
with Session(engine) as session:
records = session.scalars(select(TestTable)).all()
print("We restored the following values in code...")
for record in records:
print(record.value)
We restored the following values in code...
Color.LEGACY_RED
Color.GREEN
Color.BLUE
Color.UNKNOWN
And fixed! We obviously have changed our application logic, but this is now much safer and code which will work as we expect it too in all circumstances.
From a practical perspective we've had to expand our design space to assume indeterminate colors can exist - which might be awkward, but the trade-off is robustness: our application logic can now choose how it handles "unknown" - we could crash if we wanted, but we can also choose just to ignore those records we don't understand or display them as "unknown" and prevent user interaction or whatever else we want.
Discussion
This is an interesting case where in my opinion the "default" design isn't what you would want, but the logic for it is actually sound. SQLAlchemy models define databases - they are principally built on assuming you are describing the actual state of a database, with constraints provided by a database - i.e. in a database with first-class enumeration support, some of the tripwires here just wouldn't work without a schema upgrade.
Conversely, if you did a schema upgrade, your old applications still wouldn't know how to parse new values unless you did everything perfectly in lockstep - which in my experience isn't reality.
Basically it's an interesting case where everything is justifiably right, but leaves some design footguns lying around which might be a bit of a surprise (hence this post). The kicker for me is the effect on using session.scalar calls to return models - since unless we're querying more specifically, having unknown values we can't handle in tables leads to being unable to list any elements on the table ergonomically.
Conclusions
Think carefully before using automagic enum methods in SQLAlchemy. What you want to do now is likely subtly wrong, and while there's a simple and elegant way to use enum.Enum with SQLAlchemy, the magic will give you working code quickly but with potentially nasty problems from subtle bugs or data mismatches later.
Listings
The full listing for the code samples here can also be found here.
sqlalchemy-enums.py (Source)
#!/usr/bin/env python
# sqlalchemy-enums.py
# Note: you need to at least install `pip install sqlalchemy` for this to work.
from enum import Enum
import sqlalchemy
from sqlalchemy.orm import Mapped, DeclarativeBase, Session, mapped_column
from sqlalchemy import create_engine, select, text
import typing as t
class EnumWithUnknown(sqlalchemy.Enum):
def __init__(self, *enums, **kw: t.Any):
super().__init__(*enums, **kw)
# SQLAlchemy sets the _adapted_from keyword argument sometimes, which contains a reference to the original type - but won't include
# original keyword arguments, so we need to handle that here.
self._unknown_value = (
kw["_adapted_from"]._unknown_value
if "_adapted_from" in kw
else kw.get("unknown_value", None)
)
if self._unknown_value is None:
raise ValueError("unknown_value should be a member of the enum")
# This is the function which resolves the object for the DB value
def _object_value_for_elem(self, elem):
try:
return self._object_lookup[elem]
except LookupError:
return self._unknown_value
class Color(Enum):
UNKNOWN = "unknown"
LEGACY_RED = "red"
GREEN = "green"
BLUE = "blue"
class Base(DeclarativeBase):
pass
class TestTable(Base):
__tablename__ = "test_table"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Color] = mapped_column(
EnumWithUnknown(
Color,
values_callable=lambda t: [str(item.value) for item in t],
unknown_value=Color.UNKNOWN,
)
)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as session:
# Create normal values
for enum_item in [Color.LEGACY_RED, Color.GREEN, Color.BLUE]:
session.add(TestTable(value=enum_item))
session.commit()
with Session(engine) as session:
session.add(TestTable(value="reed"))
session.commit()
# Now try and read the values back
with Session(engine) as session:
records = session.scalars(select(TestTable)).all()
print("We restored the following values in code...")
for record in records:
print(record.value)
print("But the underlying table contains...")
with engine.connect() as conn:
print(conn.execute(text("SELECT * FROM test_table;")).all())

File diff suppressed because it is too large Load Diff

View File

@ -1,635 +0,0 @@
# Justfile patterns for production full-stack development in 2025
The `just` command runner (currently at **version 1.43.1** as of November 2025) has matured into an excellent choice for full-stack monorepo orchestration, offering native module support for organizing multi-service commands, **parallel dependency execution** via the new `[parallel]` attribute, and robust cross-platform compatibility through shell configuration. For your FastAPI + React + Docker Compose stack, the recommended architecture uses a root justfile with service-specific modules (`backend.just`, `frontend.just`, `db.just`), enabling clean namespaced commands like `just backend::test` while keeping orchestration commands at the root level. This approach eliminates the complexity of Makefiles while providing significantly more power than npm scripts.
## Current just features and 2024-2025 additions
Just has seen substantial feature additions over the past year that directly benefit full-stack development workflows. The **module system** (`mod` statement) was stabilized in version 1.31.0, enabling proper monorepo organization with namespaced recipes. Version 1.42.0 introduced the **`[parallel]` attribute** for concurrent dependency execution and **cross-submodule dependencies**, allowing recipes to depend on recipes in other modules (`deploy: utils::build`). The **`[script]` attribute** (1.33.0) enables writing recipes in any language without shebang workarounds, and the **`[group]` attribute** (1.27.0) organizes recipes into logical categories in help output.
New built-in functions added in 2024-2025 include `which()` and `require()` for finding executables (with `require()` erroring if not found), `read()` for file contents, and path constants `PATH_SEP` and `PATH_VAR_SEP` for cross-platform path handling. The `dotenv-override` setting now allows `.env` files to override existing environment variables, useful for Docker-based development where container environment variables might conflict with local configuration.
```just
# Core settings block for a 2025 production justfile
set dotenv-load # Auto-load .env
set export # Export all variables
set shell := ["bash", "-uc"] # Bash with error checking
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
```
## Monorepo organization with the module system
The recommended structure for a full-stack monorepo uses a **root justfile for orchestration** combined with **service modules** for domain-specific commands. Just searches for module files in a specific order: `foo.just`, `foo/mod.just`, `foo/justfile`, or `foo/.justfile`, giving you flexibility in organizing your project.
```
project/
├── justfile # Root orchestration (dev, build, test-all, deploy)
├── backend.just # Backend module (migrations, backend tests, lint)
├── frontend.just # Frontend module (build, dev server, frontend tests)
├── db.just # Database module (backup, restore, shell)
├── docker.just # Shared Docker utilities (imported, not modularized)
├── backend/
├── frontend/
└── docker-compose.yml
```
The distinction between `mod` and `import` is critical: **`mod` creates namespaced recipes** accessed via `just backend::test`, while **`import` merges recipes** into the current namespace without prefixes. For service-specific commands, modules provide cleaner organization; for shared utilities like Docker helpers, imports work better.
```just
# Root justfile
mod backend # Creates just backend::* namespace
mod frontend
mod db
import 'docker.just' # Merges into root namespace
# Start everything
dev:
docker compose up
# Run all tests across services
test:
just backend::test
just frontend::test
```
Module recipes should use the **`[no-cd]` attribute** to ensure they execute from the project root rather than the module file's directory, since Docker Compose commands need access to the root `docker-compose.yml`:
```just
# backend.just
[no-cd]
test *ARGS:
docker compose exec backend pytest {{ARGS}}
```
## Docker Compose integration patterns
Docker Compose integration forms the backbone of full-stack development workflows. The key patterns involve **variadic arguments for passthrough** (`*ARGS`), **parameterized compose files** for environment switching, and **service-specific exec commands**.
```just
# Essential Docker Compose recipes
@up *ARGS:
docker compose up {{ARGS}}
@start *ARGS:
docker compose up -d {{ARGS}}
@down *ARGS:
docker compose down {{ARGS}}
@build *ARGS:
docker compose build {{ARGS}}
@logs *SERVICE:
docker compose logs -f {{SERVICE}}
# Execute in running container
@exec service *CMD:
docker compose exec {{service}} {{CMD}}
# One-off command (new container)
@run service *CMD:
docker compose run --rm {{service}} {{CMD}}
# Interactive shell access
shell service='backend':
docker compose exec -it {{service}} /bin/bash
```
For **environment-specific deployments**, parameterize the compose file selection:
```just
# Parameterized environment handling
up-dev:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
up-prod:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Or with a parameter
deploy env='staging':
docker compose -f docker-compose.yml -f docker-compose.{{env}}.yml up -d
```
**Docker Compose profiles** integrate naturally with environment variables:
```just
export COMPOSE_PROFILES := env_var_or_default('COMPOSE_PROFILES', 'default')
# Usage: COMPOSE_PROFILES=workers just up
```
## Recipe parameters and dependency patterns
Just offers sophisticated parameter handling including **required parameters**, **optional with defaults**, and **variadic parameters** (both mandatory `+` and optional `*`). Default values can be expressions, enabling dynamic defaults based on environment or other variables.
```just
# Required parameter
deploy environment:
echo "Deploying to {{environment}}"
# Optional with default
serve port='8000' host='0.0.0.0':
uvicorn main:app --host {{host}} --port {{port}}
# Variadic (one or more required)
backup +tables:
pg_dump {{tables}}
# Variadic (zero or more optional)
test *ARGS:
pytest {{ARGS}}
# Mixed parameters with expression default
arch := 'amd64'
build target os=os() architecture=arch:
docker build --platform {{os}}/{{architecture}} -t {{target}} .
```
Dependencies between recipes can now execute **in parallel** using the `[parallel]` attribute introduced in version 1.42.0:
```just
[parallel]
ci: lint typecheck test build
@echo "All checks passed"
lint:
just backend::lint
just frontend::lint
typecheck:
just backend::typecheck
just frontend::typecheck
test:
just backend::test
just frontend::test
build:
docker compose build
```
**Cross-submodule dependencies** allow recipes to depend on recipes in other modules:
```just
# Root justfile
mod backend
mod frontend
deploy: backend::build frontend::build
docker compose -f docker-compose.prod.yml up -d
```
## Variables, conditionals, and platform handling
Just's expression system supports **conditionals**, **environment variable access**, **command substitution**, and **platform detection**. These features enable cross-platform justfiles that work on Windows, macOS, and Linux.
```just
# Platform-specific commands using conditionals
browse := if os() == "linux" { "xdg-open" } else if os() == "macos" { "open" } else { "start" }
sed_inplace := if os() == "linux" { "sed -i" } else { "sed -i '' -e" }
# Environment variables with fallbacks
db_host := env('DATABASE_HOST', 'localhost')
db_port := env('DATABASE_PORT', '5432')
# Command substitution via backticks
git_hash := `git rev-parse --short HEAD`
current_branch := `git branch --show-current 2>/dev/null || echo "main"`
# Dynamic values based on environment
build_mode := if env('CI', '') == 'true' { 'release' } else { 'debug' }
```
For **platform-specific recipes**, use the OS attributes:
```just
[linux]
install-deps:
sudo apt install postgresql-client
[macos]
install-deps:
brew install postgresql
[windows]
install-deps:
choco install postgresql
```
## Built-in functions for production workflows
Just provides an extensive function library. The most useful for full-stack development include path manipulation, environment access, system information, and the new executable-finding functions.
| Category | Functions | Use Case |
|----------|-----------|----------|
| **Path** | `justfile_directory()`, `parent_directory()`, `join()` | Constructing paths relative to project root |
| **Environment** | `env(key, default)`, `require()`, `which()` | Configuration and dependency checking |
| **System** | `os()`, `arch()`, `os_family()`, `num_cpus()` | Cross-platform logic |
| **Files** | `path_exists()`, `read()`, `sha256_file()` | File validation and checksums |
| **Strings** | `replace()`, `trim()`, `kebabcase()` | String manipulation |
```just
# Practical examples
project_root := justfile_directory()
scripts_dir := project_root / "scripts"
config_file := project_root / "config" / "settings.yaml"
# Validate required tools exist
cargo := require('cargo')
docker := require('docker')
# Generate cache keys
config_hash := sha256_file('requirements.txt')
```
## Error handling and user feedback
Just provides several mechanisms for **controlling error behavior** and **user feedback**. The `-` prefix ignores command failures, the `[confirm]` attribute requires user confirmation for dangerous operations, and `[no-exit-message]` suppresses error messages for wrapper recipes.
```just
# Continue on error (useful for cleanup)
clean:
-rm -rf build/
-rm -rf dist/
-rm -rf .cache/
@echo "Cleanup complete"
# Require confirmation for destructive operations
[confirm("This will DELETE the production database. Are you sure?")]
[group('danger')]
db-drop-prod:
docker compose -f docker-compose.prod.yml exec db dropdb production
# Suppress just's error message (the tool's own message is enough)
[no-exit-message]
git *args:
git {{args}}
```
For **validation at parse time**, use the `error()` function in expressions:
```just
required_env := if env('API_KEY', '') == '' { error("API_KEY environment variable is required") } else { env('API_KEY') }
```
## Documentation and help organization
Self-documenting justfiles use **comments above recipes** (shown in `just --list`), the **`[doc()]` attribute** for custom descriptions, and **`[group()]`** for logical organization. Private helper recipes use the **underscore prefix**.
```just
# Show available commands (default recipe)
default:
@just --list --unsorted
# Build Docker images for all services
[group('build')]
build:
docker compose build
# Run database migrations
[group('database')]
migrate *ARGS:
docker compose exec backend alembic upgrade {{ARGS}}
# Create a new migration file
[doc("Generate migration from model changes")]
[group('database')]
migration message:
docker compose exec backend alembic revision --autogenerate -m "{{message}}"
# Internal helper (hidden from --list)
[private]
_ensure-docker:
@docker info > /dev/null 2>&1 || (echo "Docker not running" && exit 1)
```
Running `just --list` with groups produces organized output:
```
Available recipes:
default
[build]
build # Build Docker images for all services
[database]
migrate *ARGS # Run database migrations
migration message # Generate migration from model changes
```
## CI/CD integration with GitHub Actions
Installing just in CI pipelines uses either the **official setup-just action** or the **install script with version pinning**. Pin versions in CI to avoid unexpected breakage.
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v3
with:
just-version: '1.43.1'
- name: Run CI checks
run: just ci
```
Create a dedicated **CI recipe** that runs all checks:
```just
# CI-specific recipe that fails fast on any issue
ci: lint typecheck test build
@echo "✅ All CI checks passed"
# Local development doesn't need all checks
dev:
docker compose up
```
For **environment variable handling** in CI, rely on the `env()` function with defaults rather than assuming variables exist:
```just
# Works both locally (with .env) and in CI (with secrets)
set dotenv-load
db_url := env('DATABASE_URL', 'postgresql://localhost/dev')
```
## Complete production justfile template
This template incorporates all the patterns discussed for a FastAPI + React + PostgreSQL + Docker Compose stack:
```just
# =============================================================================
# Full-Stack Development Commands
# =============================================================================
set dotenv-load
set export
set shell := ["bash", "-uc"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Service modules
mod backend 'backend.just'
mod frontend 'frontend.just'
mod db 'db.just'
# Project info
project := file_name(justfile_directory())
version := `git describe --tags --always 2>/dev/null || echo "dev"`
# =============================================================================
# Core Commands
# =============================================================================
# List available commands
default:
@just --list --unsorted
# Start development environment
dev:
docker compose up
# Start services in background
start:
docker compose up -d
# Stop all services
stop:
docker compose down
# Stop and remove volumes (fresh start)
[confirm("Remove all volumes and data?")]
clean:
docker compose down -v --remove-orphans
# View service logs
logs *SERVICE:
docker compose logs -f {{SERVICE}}
# Open shell in service container
shell service='backend':
docker compose exec -it {{service}} /bin/bash
# =============================================================================
# Build and Deploy
# =============================================================================
# Build all Docker images
[group('build')]
build *ARGS:
docker compose build {{ARGS}}
# Rebuild from scratch (no cache)
[group('build')]
rebuild:
docker compose build --no-cache
# Build production images
[group('build')]
build-prod:
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
# Deploy to staging
[group('deploy')]
[confirm("Deploy to staging?")]
deploy-staging: ci build-prod
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
# Deploy to production
[group('deploy')]
[confirm("Deploy to PRODUCTION?")]
deploy-prod: ci build-prod
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# =============================================================================
# Testing and Quality
# =============================================================================
# Run all tests
[group('test')]
test:
just backend::test
just frontend::test
# Run linters
[group('test')]
lint:
just backend::lint
just frontend::lint
# Type checking
[group('test')]
typecheck:
just backend::typecheck
just frontend::typecheck
# Format all code
[group('test')]
format:
just backend::format
just frontend::format
# CI pipeline (runs all checks)
[group('test')]
[parallel]
ci: lint typecheck test
@echo "✅ All checks passed"
# =============================================================================
# Database
# =============================================================================
# Run migrations
[group('database')]
migrate *ARGS:
just db::migrate {{ARGS}}
# Create new migration
[group('database')]
migration message:
just db::migration "{{message}}"
# Reset database (dangerous)
[group('database')]
[confirm("Reset the database? All data will be lost.")]
db-reset:
just db::reset
just migrate
# =============================================================================
# Setup
# =============================================================================
# First-time project setup
setup:
@echo "Setting up {{project}}..."
cp -n .env.example .env || true
docker compose build
docker compose up -d
just migrate
@echo "✅ Setup complete. Run 'just dev' to start."
# =============================================================================
# Utilities
# =============================================================================
# Show running containers
ps:
docker compose ps
# Docker system cleanup
[private]
docker-prune:
docker system prune -f
# Print project info
info:
@echo "Project: {{project}}"
@echo "Version: {{version}}"
@echo "OS: {{os()}} ({{arch()}})"
```
```just
# backend.just - Backend service commands
[no-cd]
# Run backend tests
test *ARGS:
docker compose exec backend pytest {{ARGS}}
# Run specific test file
test-file file:
docker compose exec backend pytest {{file}} -v
# Lint backend code
lint:
docker compose exec backend ruff check .
docker compose exec backend ruff format --check .
# Type checking
typecheck:
docker compose exec backend mypy src/
# Format backend code
format:
docker compose exec backend ruff format .
docker compose exec backend ruff check --fix .
# Python REPL
repl:
docker compose exec backend python
# Install new dependency
add package:
docker compose exec backend pip install {{package}}
docker compose exec backend pip freeze > requirements.txt
```
```just
# db.just - Database commands
[no-cd]
DATABASE_URL := env('DATABASE_URL', 'postgresql://postgres:postgres@db/app')
# Run migrations
migrate *ARGS='head':
docker compose exec backend alembic upgrade {{ARGS}}
# Create new migration
migration message:
docker compose exec backend alembic revision --autogenerate -m "{{message}}"
# Rollback last migration
rollback:
docker compose exec backend alembic downgrade -1
# PostgreSQL shell
psql:
docker compose exec db psql -U postgres app
# Create database backup
backup:
#!/usr/bin/env bash
timestamp=$(date +%Y%m%d_%H%M%S)
docker compose exec db pg_dump -U postgres app > "backups/db_${timestamp}.sql"
echo "Backup created: backups/db_${timestamp}.sql"
# Restore from backup
restore file:
docker compose exec -T db psql -U postgres app < {{file}}
# Reset database (drop and recreate)
reset:
docker compose exec db dropdb -U postgres --if-exists app
docker compose exec db createdb -U postgres app
```
## Common pitfalls to avoid
Several patterns consistently cause problems in production justfiles. **Avoid relative paths with `../`**—use `justfile_directory()` instead, as relative paths break when the working directory changes. **Always set `windows-shell`** if your team includes Windows users, since the default `sh` isn't available natively on Windows. **Don't set `fallback := true` in the root justfile**, as this can accidentally invoke user-level justfile recipes. **Keep recipes focused**—if a recipe exceeds 10-15 lines, split it into smaller dependent recipes or use a shebang script. **Pin versions in CI** to avoid breaking changes from automatic just updates.
The `error()` function evaluates at **parse time, not runtime**, so conditional error messages based on runtime state won't work as expected. For runtime validation, use shell conditionals within recipes instead.
## Debugging justfiles effectively
Just provides several tools for troubleshooting. Use `just --dry-run recipe` to see commands without executing them, `just --evaluate` to print all variable values, `just --show recipe` to display a recipe's source, and `just -vv recipe` for verbose execution. The `just --dump --dump-format json` command outputs the parsed justfile as JSON, useful for debugging complex expression evaluation.
```bash
# See what would run without executing
just --dry-run deploy-prod
# Check variable values
just --evaluate
# Debug a specific recipe
just --show migrate
# Maximum verbosity
just -vv ci
```
For cross-platform testing, test on all target platforms or use CI matrix builds to catch platform-specific issues early. The combination of OS-specific attributes (`[linux]`, `[macos]`, `[windows]`) and conditional expressions provides comprehensive cross-platform support when used correctly.

View File

@ -1,499 +0,0 @@
# JWT Authentication for FastAPI + PostgreSQL: 2025 Production Patterns
**The recommended stack for production JWT authentication in 2025 is PyJWT 2.10+ with Argon2id (via argon2-cffi), rotating refresh tokens stored hashed in PostgreSQL, and Redis for rate limiting and instant revocation.** FastAPI's deprecation of python-jose in favor of PyJWT, combined with passlib's unmaintained status since 2020, marks a significant shift in the ecosystem. This report covers the deep implementation patterns, anti-patterns, and security considerations needed to build a production-ready auth system.
---
## JWT library landscape has shifted dramatically
**PyJWT 2.10.1** is now the FastAPI-endorsed choice after python-jose's effective deprecation (last meaningful release ~2021). Key 2024-2025 updates include built-in `sub` and `jti` claim validation, `strict_aud` for stricter audience checks, and Ed448/EdDSA support.
```python
# Production PyJWT configuration (2025)
import jwt
from datetime import datetime, timedelta, UTC
def create_access_token(user_id: str, roles: list[str]) -> str:
return jwt.encode(
{
"sub": user_id,
"exp": datetime.now(UTC) + timedelta(minutes=15),
"iat": datetime.now(UTC),
"jti": str(uuid.uuid4()), # For revocation
"type": "access",
"roles": roles,
"token_version": user.token_version, # For "logout all"
},
settings.JWT_SECRET_KEY.get_secret_value(),
algorithm="HS256"
)
def decode_token(token: str) -> dict:
return jwt.decode(
token,
settings.JWT_SECRET_KEY.get_secret_value(),
algorithms=["HS256"], # NEVER trust token header
options={"require": ["exp", "sub", "jti", "iat"]}
)
```
**joserfc** (from Authlib team) emerges as the modern alternative when JWE encryption is needed or stricter type safety is required. For most FastAPI applications, PyJWT remains the pragmatic choice due to ecosystem maturity and FastAPI documentation alignment.
### Token expiration and rotation strategy
Production consensus for **access tokens is 15 minutes**, **refresh tokens 7-14 days with rotation**. The critical pattern is **rotating refresh tokens with token families** for theft detection:
```python
# Token family pattern prevents replay attacks
{
"sub": "user_123",
"jti": "unique-token-id",
"family_id": "auth-session-uuid", # All tokens from same login share this
"type": "refresh",
"exp": 1234567890
}
```
When a refresh token is used, issue a **new refresh token and invalidate the old one**. If a previously-invalidated token is presented (replay attack), **revoke the entire token family** immediately—this catches the race condition between legitimate user and attacker.
### Storage strategy: Memory + HttpOnly cookies
The 2025 consensus is clear: **access tokens in JavaScript memory** (React state/closures, never localStorage), **refresh tokens in HttpOnly cookies**:
```python
def set_refresh_cookie(response: Response, token: str):
response.set_cookie(
key="refresh_token",
value=token,
httponly=True,
secure=True, # HTTPS only
samesite="strict", # CSRF protection
max_age=604800, # 7 days
path="/api/auth/refresh" # Limit scope to refresh endpoint
)
```
For high-security applications, the **Backend-for-Frontend (BFF) pattern** keeps tokens entirely server-side—the SPA receives only a session cookie while the BFF proxies API requests with injected access tokens.
---
## Password hashing requires Argon2id with specific parameters
**passlib is effectively dead**—last release October 2020, breaks on Python 3.13+. Use **argon2-cffi 25.1.0** or the newer **pwdlib 0.3.0** (from FastAPI-users maintainer).
OWASP's 2025 minimum parameters for Argon2id: **19 MiB memory, 2 iterations, 1 parallelism**, targeting ~500ms hash time:
```python
from argon2 import PasswordHasher
import asyncio
# OWASP minimum configuration
ph = PasswordHasher(
time_cost=2, # iterations
memory_cost=19456, # 19 MiB
parallelism=1, # Important: p=1 for async FastAPI
hash_len=32,
salt_len=16
)
async def hash_password(password: str) -> str:
# CPU-bound - run in thread pool for async FastAPI
return await asyncio.to_thread(ph.hash, password)
async def verify_password(hash: str, password: str) -> tuple[bool, str | None]:
"""Returns (is_valid, new_hash_if_needs_rehash)"""
try:
await asyncio.to_thread(ph.verify, hash, password)
if ph.check_needs_rehash(hash): # Auto-upgrade old parameters
return True, await hash_password(password)
return True, None
except:
return False, None
```
**Argon2id handles salts internally**—16-byte salt is automatically generated and embedded in the output hash. Separate salt storage is unnecessary. For additional defense, a **pepper** (application-level secret stored in secrets vault) can wrap the hash via HMAC, but requires all users to reset passwords if the pepper changes.
### Password reset must prevent timing attacks and enumeration
```python
async def request_reset(email: str):
start = datetime.now()
user = await get_user_by_email(email)
if user:
token = secrets.token_urlsafe(32) # 256 bits
token_hash = hashlib.sha256(token.encode()).hexdigest()
await store_reset_token(user.id, token_hash, expires=timedelta(hours=1))
await send_email(email, token)
# CRITICAL: Consistent timing prevents user enumeration
elapsed = (datetime.now() - start).total_seconds()
if elapsed < 0.5:
await asyncio.sleep(0.5 - elapsed)
# Always same response
return {"message": "If an account exists, reset email sent"}
```
Store reset tokens as **SHA-256 hashes** (treat like passwords), expire within **15-60 minutes**, enforce **one-time use** by marking used before changing password.
---
## FastAPI auth patterns favor dependencies over middleware
**Dependencies are the FastAPI-native pattern for authentication**—more testable, composable, and efficient than middleware which runs on every request including `/docs`.
### Standard dependency chain
```python
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="api/v1/auth/token",
scopes={"read": "Read access", "write": "Write access", "admin": "Admin access"}
)
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)]
) -> User:
try:
payload = decode_token(token)
if await is_token_revoked(payload["jti"]):
raise HTTPException(status_code=401, detail="Token revoked")
user = await get_user(payload["sub"])
if payload.get("token_version") != user.token_version:
raise HTTPException(status_code=401, detail="Session invalidated")
return user
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
async def get_current_active_user(
user: Annotated[User, Depends(get_current_user)]
) -> User:
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
async def get_admin_user(
user: Annotated[User, Depends(get_current_active_user)]
) -> User:
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Admin required")
return user
```
### Optional authentication uses auto_error=False
```python
optional_oauth2 = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
async def get_optional_user(
token: str | None = Depends(optional_oauth2)
) -> User | None:
if not token:
return None
try:
payload = decode_token(token)
return await get_user(payload["sub"])
except:
return None
```
### RBAC with callable permission checker classes
```python
class PermissionChecker:
def __init__(self, required_permissions: list[str]):
self.required = required_permissions
def __call__(self, user: User = Depends(get_current_active_user)):
for perm in self.required:
if perm not in user.permissions:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return True
# Usage
@app.get("/items/", dependencies=[Depends(PermissionChecker(["read:items"]))])
def list_items(): ...
@app.delete("/items/{id}", dependencies=[Depends(PermissionChecker(["delete:items"]))])
def delete_item(id: int): ...
```
For **route group auth**, use APIRouter dependencies:
```python
protected_router = APIRouter(
prefix="/api/v1",
dependencies=[Depends(get_current_active_user)]
)
admin_router = APIRouter(
prefix="/api/v1/admin",
dependencies=[Depends(get_admin_user)]
)
```
---
## Database schema requires hashed token storage and family tracking
**Use UUID primary keys** for user tables—the ~13% performance penalty versus integers is acceptable for the security benefit of not exposing record counts. PostgreSQL 18 (Fall 2025) will introduce UUID v7 with timestamp ordering for better index performance.
### Core user model
```python
class User(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(1024))
is_active: Mapped[bool] = mapped_column(default=True)
is_verified: Mapped[bool] = mapped_column(default=False)
is_superuser: Mapped[bool] = mapped_column(default=False)
# Critical for "logout all devices"
token_version: Mapped[int] = mapped_column(default=0)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(onupdate=func.now())
last_login: Mapped[datetime | None]
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin" # Required for async SQLAlchemy
)
```
### Refresh token table with family tracking
```python
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
# ALWAYS hash stored tokens
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
# Device tracking for "active sessions" UI
device_id: Mapped[str | None] = mapped_column(String(255))
device_name: Mapped[str | None] = mapped_column(String(100))
ip_address: Mapped[str | None] = mapped_column(String(45)) # IPv6 length
# Token family for rotation attack detection
family_id: Mapped[UUID] = mapped_column(default=uuid4, index=True)
expires_at: Mapped[datetime] = mapped_column(index=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
is_revoked: Mapped[bool] = mapped_column(default=False)
revoked_at: Mapped[datetime | None]
```
### Token rotation with replay attack detection
```python
async def rotate_refresh_token(db: AsyncSession, old_token: str, user_id: UUID) -> tuple[str, str]:
old_hash = hashlib.sha256(old_token.encode()).hexdigest()
existing = await db.scalar(
select(RefreshToken).where(
RefreshToken.token_hash == old_hash,
RefreshToken.is_revoked == False
)
)
if not existing:
# REPLAY ATTACK - Revoke entire token family
await db.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user_id)
.values(is_revoked=True, revoked_at=func.now())
)
await db.commit()
raise SecurityException("Token reuse detected - all sessions revoked")
# Revoke old, issue new with same family
existing.is_revoked = True
existing.revoked_at = datetime.now(UTC)
new_token = secrets.token_urlsafe(32)
new_refresh = RefreshToken(
token_hash=hashlib.sha256(new_token.encode()).hexdigest(),
user_id=user_id,
family_id=existing.family_id, # Same family
expires_at=datetime.now(UTC) + timedelta(days=7)
)
db.add(new_refresh)
await db.commit()
return new_token, create_access_token(user_id)
```
### "Logout all devices" via token_version
```python
async def logout_all_devices(db: AsyncSession, user_id: UUID):
await db.execute(
update(User).where(User.id == user_id)
.values(token_version=User.token_version + 1)
)
await db.execute(
update(RefreshToken).where(RefreshToken.user_id == user_id)
.values(is_revoked=True)
)
await db.commit()
```
All access tokens immediately become invalid when `token_version` in the token doesn't match the user's current `token_version`.
---
## Security hardening requires defense in depth
### CORS with credentials requires explicit origins
```python
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourapp.com"], # NEVER ["*"] with credentials
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization", "X-CSRF-Token"],
max_age=600, # Cache preflight 10 minutes
)
```
### Rate limiting is mandatory for auth endpoints
```python
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://localhost:6379"
)
@app.post("/auth/login")
@limiter.limit("5/minute")
async def login(request: Request): ...
@app.post("/auth/password-reset")
@limiter.limit("3/hour")
async def password_reset(request: Request): ...
```
Implement **progressive lockout**: 5 failed attempts → 1 minute lock, 10 → 5 minutes, 15 → 30 minutes.
### Timing attack prevention requires dummy operations
```python
# Pre-compute at startup
DUMMY_HASH = ph.hash("dummy_password_for_timing")
async def authenticate(username: str, password: str) -> User | None:
user = await get_user_by_email(username)
if user is None:
# Perform dummy hash to prevent timing attack
await asyncio.to_thread(ph.verify, password, DUMMY_HASH)
return None
valid, _ = await verify_password(user.hashed_password, password)
return user if valid else None
```
Use `secrets.compare_digest()` for all token comparisons—never `==`.
### RS256 over HS256 for production multi-service architectures
For microservices, **RS256 (asymmetric)** allows distributing public keys for verification while keeping private keys isolated. Key rotation becomes simpler—only public keys need updating across services.
```python
# Key rotation with kid (key ID) in header
def sign_token(payload: dict, private_key: str, kid: str) -> str:
return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid})
def verify_token(token: str, public_keys: dict) -> dict:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
return jwt.decode(token, public_keys[kid], algorithms=["RS256"])
```
---
## Production deployment checklist
### Secrets management with pydantic-settings
```python
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
secrets_dir="/run/secrets" # Docker secrets
)
JWT_SECRET_KEY: SecretStr
DATABASE_URL: SecretStr
REDIS_URL: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
```
### Auth event logging (GDPR-compliant)
**Log**: login attempts/success/failure, password changes, token refresh, session invalidation
**Never log**: passwords, full tokens, session IDs, unnecessary PII
```python
logger.info("auth_event",
event_type="login_success",
user_id=user.id, # Pseudonymized ID, not email
ip_address=mask_ip(request.client.host), # Mask last octet
timestamp=datetime.utcnow().isoformat()
)
```
### Docker security essentials
```dockerfile
# Non-root user
RUN useradd -r -g appgroup appuser
USER appuser
# Multi-stage build
FROM python:3.12-slim AS runtime
COPY --from=builder /app/requirements.txt .
```
Use Docker secrets (`/run/secrets/`) instead of environment variables for sensitive data in production.
---
## Critical anti-patterns to avoid
- **Storing sensitive data in JWT payload** - tokens are base64, not encrypted
- **Not explicitly specifying algorithms** on decode - enables algorithm confusion attacks
- **Using passlib in new projects** - unmaintained, breaks on Python 3.13+
- **Trusting `alg` header from token** - always validate with explicit `algorithms=["HS256"]`
- **Using `["*"]` with `allow_credentials=True`** in CORS - browsers reject this
- **Comparing tokens with `==`** instead of `secrets.compare_digest()`
- **Not running password hashing in thread pool** - blocks async event loop
- **Storing raw refresh tokens** - always hash before storage
- **Missing token family tracking** - enables silent replay attacks
## Conclusion
Building production-ready JWT authentication in FastAPI requires careful attention to the shifting ecosystem (PyJWT over python-jose, argon2-cffi over passlib), proper token rotation with family tracking, and defense-in-depth security measures. The patterns outlined here—dependency-based auth, hashed token storage, timing attack prevention, and progressive rate limiting—form a robust foundation that scales from single applications to distributed microservices architectures.

View File

@ -1,979 +0,0 @@
# Production-Grade Nginx Configuration: 2025 Best Practices
**Complete guide for modern full-stack applications (FastAPI + Frontend) with emphasis on WebSocket support, configuration organization, and dev/prod architectures.**
## Table of Contents
1. [Configuration File Organization](#configuration-file-organization)
2. [WebSocket Proxying](#websocket-proxying)
3. [Shared Configuration (http.conf)](#shared-configuration-httpconf)
4. [Development Configuration](#development-configuration-devnginx)
5. [Production Configuration](#production-configuration-prodnginx)
6. [Performance Optimization](#performance-optimization)
7. [Security Headers](#security-headers)
8. [Static File Serving](#static-file-serving-production)
9. [Vite Dev Server Integration](#vite-dev-server-integration-development)
10. [Load Balancing & Upstreams](#load-balancing--upstreams)
11. [Rate Limiting](#rate-limiting)
12. [Logging & Monitoring](#logging--monitoring)
13. [Complete Configuration Examples](#complete-configuration-examples)
---
## Configuration File Organization
### Recommended Structure
```
conf/nginx/
├── http.conf # Shared: upstreams, maps, global http settings
├── dev.nginx # Full nginx.conf for development
└── prod.nginx # Full nginx.conf for production
```
### What Goes Where
**http.conf (Shared Configurations)**:
- Upstream definitions (backend, frontend servers)
- Map directives for WebSocket connection upgrades
- Shared rate limit zones
- Common proxy settings
- MIME type definitions
- Log formats
**dev.nginx (Development-Specific)**:
- Full nginx.conf structure
- Includes http.conf
- Proxies to Vite dev server (port 5173)
- Proxies API/WebSocket to backend (port 8000)
- Verbose logging
- CORS permissive settings
- No caching
- No SSL (typically)
**prod.nginx (Production-Specific)**:
- Full nginx.conf structure
- Includes http.conf
- Serves static files from `/usr/share/nginx/html`
- Proxies API/WebSocket to Gunicorn workers
- SSL/TLS configuration
- Security headers
- Gzip/Brotli compression
- Static file caching
- Error logging only
---
## WebSocket Proxying
WebSocket connections require explicit handling because the "Upgrade" and "Connection" headers are hop-by-hop and not automatically passed to the proxied server. The modern approach uses a `map` directive to handle connections conditionally.
### Core WebSocket Configuration Pattern
```nginx
# In http.conf (shared configuration)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
This sophisticated approach sets the Connection header to "close" when there's no Upgrade header, and to "upgrade" when WebSocket upgrade is requested.
### WebSocket Location Block
```nginx
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
# WebSocket-specific headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeout settings for long-lived connections
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 75s;
# Disable buffering for real-time data
proxy_buffering off;
proxy_cache_bypass $http_upgrade;
}
```
### Critical WebSocket Settings Explained
For WebSocket session persistence with multiple backend servers, use `ip_hash` to ensure clients always connect to the same backend:
```nginx
upstream websocket_backend {
ip_hash; # Session persistence
server backend1:8000;
server backend2:8000;
server backend3:8000;
}
```
**Timeout Configuration**: By default, connections close if the proxied server doesn't transmit data within 60 seconds. For WebSockets:
- `proxy_read_timeout`: Set to 3600s (1 hour) or higher
- `proxy_send_timeout`: Set to 3600s (1 hour) or higher
- `proxy_connect_timeout`: Usually 75s is sufficient
---
## Shared Configuration (http.conf)
This file contains settings used by both dev and prod environments.
```nginx
# conf/nginx/http.conf
# WebSocket upgrade handling
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Upstream definitions
upstream backend {
# Development: single uvicorn instance
# Production: multiple gunicorn workers (override in prod.nginx)
server backend:8000 max_fails=3 fail_timeout=30s;
keepalive 32; # Connection pooling
}
upstream frontend_dev {
# Only used in development
server frontend:5173;
}
# Custom log format with timing information
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
# Rate limit zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=1r/s;
# Connection limits
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# Common proxy settings
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
---
## Development Configuration (dev.nginx)
Full nginx.conf optimized for local development with hot module replacement.
```nginx
# conf/nginx/dev.nginx
user nginx;
worker_processes 1; # Single worker sufficient for dev
error_log /var/log/nginx/error.log debug; # Verbose logging
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Include shared configuration
include /etc/nginx/http.conf;
# Development-specific settings
access_log /var/log/nginx/access.log main_timed;
sendfile off; # Disable for file system changes
tcp_nopush off;
tcp_nodelay on;
keepalive_timeout 65;
# Disable caching in development
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
# CORS permissive for development
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
server {
listen 80;
server_name localhost;
# Handle preflight requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
# API routes to backend
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 10;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Proxy buffering settings
proxy_buffering off;
proxy_request_buffering off;
}
# WebSocket route
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# Vite dev server (with HMR WebSocket support)
location / {
proxy_pass http://frontend_dev;
proxy_http_version 1.1;
# Required for Vite HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# Vite-specific timeout
proxy_read_timeout 60s;
proxy_buffering off;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}
```
### Key Development Features:
1. **Verbose Logging**: `error_log debug` for troubleshooting
2. **No Caching**: Ensures fresh content on every request
3. **Permissive CORS**: Allows frontend to call backend freely
4. **HMR Support**: WebSocket headers (Upgrade and Connection) are required for Vite's Hot Module Replacement to function properly
5. **Disabled Optimizations**: `sendfile off` to catch file changes immediately
---
## Production Configuration (prod.nginx)
Optimized for performance, security, and reliability.
```nginx
# conf/nginx/prod.nginx
user nginx;
worker_processes auto; # One per CPU core
worker_rlimit_nofile 100000;
error_log /var/log/nginx/error.log warn; # Only warnings and errors
pid /var/run/nginx.pid;
events {
worker_connections 4096; # High concurrency support
use epoll; # Efficient connection handling on Linux
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Include shared configuration
include /etc/nginx/http.conf;
# Logging with buffering
access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s;
# Performance optimizations
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 100;
types_hash_max_size 2048;
server_tokens off; # Hide nginx version
# File cache
open_file_cache max=10000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# Buffer sizes
client_body_buffer_size 128k;
client_header_buffer_size 16k;
client_max_body_size 10m;
large_client_header_buffers 4 16k;
# Timeouts
client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 10s;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
gzip_disable "msie6";
gzip_min_length 256;
# Redirect HTTP to HTTPS
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
# Main HTTPS server
server {
listen 443 ssl http2;
server_name yourdomain.com;
# SSL configuration
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss://yourdomain.com" always;
# Root directory for static files
root /usr/share/nginx/html;
index index.html;
# API routes with rate limiting
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 20;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
# Proxy buffering optimized for API
proxy_buffering on;
proxy_buffers 8 24k;
proxy_buffer_size 2k;
# Timeouts
proxy_connect_timeout 75s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Auth endpoints with stricter rate limiting
location /api/auth/ {
limit_req zone=auth_limit burst=5 nodelay;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# WebSocket route
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 75s;
proxy_buffering off;
}
# Static files with aggressive caching
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# SPA fallback - all non-matching routes to index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
}
```
---
## Performance Optimization
### Worker Configuration
Running one worker process per CPU core works well in most cases, and setting worker_processes to auto achieves this:
```nginx
worker_processes auto;
worker_rlimit_nofile 100000; # File descriptor limit
events {
worker_connections 4096;
use epoll; # Most efficient on Linux
multi_accept on; # Accept multiple connections at once
}
```
### Connection and Request Handling
The default worker_connections is 512, but most systems can support higher values. The optimal setting depends on server resources and traffic patterns.
**Formula**: `max_clients = worker_processes * worker_connections`
### Buffer Optimization
If buffer sizes are too low, Nginx will write to temporary files, causing excessive disk I/O:
```nginx
client_body_buffer_size 128k;
client_header_buffer_size 16k;
client_max_body_size 10m;
large_client_header_buffers 4 16k;
```
### Keepalive Connections
Keepalive connections reduce CPU and network overhead by keeping connections open longer:
```nginx
keepalive_timeout 65;
keepalive_requests 100;
upstream backend {
server backend:8000;
keepalive 32; # Idle connections to upstream
}
```
### Sendfile and TCP Optimizations
The sendfile() system call enables zero-copy data transfer, speeding up TCP transmissions without consuming CPU cycles:
```nginx
sendfile on;
tcp_nopush on; # Send headers in one packet
tcp_nodelay on; # Disable Nagle's algorithm
```
### File Caching
```nginx
open_file_cache max=10000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
```
### Compression
```nginx
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6; # Balance between CPU and compression
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
```
**Important**: Don't increase compression level too high, as it costs CPU effort without proportional throughput gains.
---
## Security Headers
### Essential Security Headers (2025)
Security headers are levers that slash XSS risk, lock browsers to HTTPS, tame third-party scripts, and protect users without touching app code.
```nginx
# HTTP Strict Transport Security (HSTS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# XSS Protection (legacy but still useful)
add_header X-XSS-Protection "1; mode=block" always;
# Referrer Policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Disable dangerous browser features
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
```
### Content Security Policy (CSP)
CSP controls where scripts, styles, images, frames, and connections can load from. Start with a restrictive policy:
```nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss://yourdomain.com" always;
```
**Best Practice**: Roll out CSP in Report-Only mode first, sending violation reports to an endpoint you control:
```nginx
# Test mode
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always;
```
### HSTS Explained
HSTS forces browsers to only use HTTPS by caching this policy for the max-age period. The `includeSubDomains` directive applies the policy to all subdomains.
**Preloading**: To add your domain to the browser preload list, include "preload" in the header and submit to hstspreload.org. This is a one-way decision—removal is difficult.
```nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
```
### SSL/TLS Best Practices
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
```
---
## Static File Serving (Production)
### Cache Headers Strategy
Use Cache-Control on static files so that CDNs and browsers can cache them effectively. The modern approach favors `Cache-Control` over `Expires`.
```nginx
# Images, fonts, media - long cache
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif|woff|woff2|ttf|eot|otf|mp4|mp3|ogg|webm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# CSS and JavaScript - long cache with versioning
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# HTML files - no caching
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
```
### Cache Busting
When files are updated, use cache-busting by appending version numbers to filenames (e.g., `style.css?v=2` or `style.v2.css`).
### SPA Routing
For Single Page Applications, route all non-file requests to index.html:
```nginx
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
```
---
## Vite Dev Server Integration (Development)
### HMR WebSocket Support
Vite's HMR requires WebSocket support with Upgrade and Connection headers set for the WebSocket connection to function:
```nginx
location / {
proxy_pass http://frontend:5173;
proxy_http_version 1.1;
# Critical for Vite HMR
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# Timeouts
proxy_read_timeout 60s;
proxy_buffering off;
}
```
### Vite HMR Path Handling
If Vite uses a custom HMR path:
```nginx
location ~* /__vite_hmr {
proxy_pass http://frontend:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
```
### Common Vite + Nginx Issues
1. **HMR not working**: Ensure WebSocket headers are set correctly
2. **Too many redirects**: Use `proxy_pass http://host;` without trailing slash to avoid path manipulation
3. **Connection timeout**: Default proxy timeouts may be too short; increase to 30-60 seconds for WebSocket connections
---
## Load Balancing & Upstreams
### Upstream Configuration
```nginx
upstream backend {
least_conn; # Route to server with fewest connections
server backend1:8000 max_fails=3 fail_timeout=30s;
server backend2:8000 max_fails=3 fail_timeout=30s;
server backend3:8000 backup; # Fallback server
keepalive 32; # Connection pooling
keepalive_requests 100;
keepalive_timeout 60s;
}
```
### Load Balancing Methods
- `round_robin` (default): Distribute requests evenly
- `least_conn`: Route to server with fewest active connections
- `ip_hash`: Consistent routing based on client IP (session persistence)
- `hash $request_uri consistent`: Route based on URI
### Health Checks (Nginx Plus)
```nginx
upstream backend {
zone backend 64k;
server backend1:8000;
server backend2:8000;
health_check interval=5s fails=3 passes=2;
}
```
---
## Rate Limiting
### Zone Definitions
```nginx
# In http block
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
```
### Application in Locations
```nginx
# General API rate limiting
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 20;
proxy_pass http://backend;
}
# Stricter for authentication
location /api/auth/ {
limit_req zone=auth_limit burst=5 nodelay;
proxy_pass http://backend;
}
```
**Parameters**:
- `rate`: Requests per second (or `r/m` for per minute)
- `burst`: Allow temporary burst above rate
- `nodelay`: Process burst requests immediately
- `limit_conn`: Max simultaneous connections
---
## Logging & Monitoring
### Custom Log Format with Timing
```nginx
log_format main_timed '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
```
### Buffered Logging for Performance
Logging every request directly to disk is expensive; buffering reduces write operations:
```nginx
access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s;
```
### Conditional Logging
```nginx
# Don't log health checks
location /health {
access_log off;
return 200 "healthy\n";
}
# Log only errors for static files
location ~* \.(jpg|png|css|js)$ {
access_log off;
error_log /var/log/nginx/static_error.log;
}
```
---
## Complete Configuration Examples
### Docker Compose Integration
```yaml
# docker-compose.prod.yml
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./conf/nginx/http.conf:/etc/nginx/http.conf:ro
- ./conf/nginx/prod.nginx:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- nginx_cache:/var/cache/nginx
depends_on:
- backend
networks:
- app_network
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
expose:
- "8000"
networks:
- app_network
volumes:
nginx_cache:
networks:
app_network:
```
### Minimal Dev Configuration
```nginx
# Absolute minimum for development
http {
include mime.types;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server backend:8000;
}
server {
listen 80;
location /api/ {
proxy_pass http://backend;
}
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
location / {
proxy_pass http://frontend:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
}
```
---
## Key Takeaways
### WebSocket Requirements
1. Map `$http_upgrade` to `$connection_upgrade`
2. Set `proxy_http_version 1.1`
3. Set headers: `Upgrade $http_upgrade` and `Connection $connection_upgrade`
4. Increase timeouts: `proxy_read_timeout 3600s`
5. Disable buffering: `proxy_buffering off`
### Dev vs Prod Differences
- **Dev**: Verbose logging, no caching, permissive CORS, proxy to Vite
- **Prod**: Error logging only, aggressive caching, strict security headers, serve static files
### File Organization
- **http.conf**: Upstreams, maps, shared settings
- **dev.nginx**: Full config optimized for development
- **prod.nginx**: Full config optimized for production
### Performance Priorities
1. Worker processes = CPU cores (`worker_processes auto`)
2. High worker connections (4096+)
3. Enable `sendfile`, `tcp_nopush`, `tcp_nodelay`
4. Buffer optimization to avoid disk I/O
5. Keepalive connections for upstreams
### Security Essentials
1. HSTS with preload for HTTPS enforcement
2. Comprehensive CSP to prevent XSS
3. X-Frame-Options to prevent clickjacking
4. Rate limiting on API endpoints
5. Hide server version (`server_tokens off`)
### Static File Serving
1. Long cache for assets (1 year with `immutable`)
2. No cache for HTML files
3. Use cache busting with versioned filenames
4. SPA fallback with `try_files $uri /index.html`

View File

@ -1,666 +0,0 @@
# Production FastAPI + SQLAlchemy + PostgreSQL Template Guide (2025)
Modern Python web development in 2025 demands async-first patterns with SQLAlchemy 2.0's native async syntax, PostgreSQL-specific optimizations, and tooling like **uv** and **Ruff** that have become the new standard. This guide covers production-ready configurations, senior-level patterns, and critical anti-patterns to avoid—focusing on what's changed in 2025 rather than rehashing tutorials.
---
## SQLAlchemy 2.0+ async patterns have fundamentally changed
The transition from SQLAlchemy 1.4 to 2.0+ introduced breaking changes that remain misunderstood. **Use `async_sessionmaker` directly**—not the legacy `sessionmaker(class_=AsyncSession)` pattern still found in older tutorials.
```python
from sqlalchemy.ext.asyncio import (
create_async_engine,
async_sessionmaker,
AsyncSession,
AsyncAttrs,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# Single engine per service, created once at startup
engine = create_async_engine(
"postgresql+asyncpg://user:pass@host/db",
pool_size=25,
max_overflow=15,
pool_timeout=30,
pool_pre_ping=True, # Critical for production
connect_args={
"command_timeout": 30,
"statement_cache_size": 200,
},
)
# Create sessionmaker factory
SessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False, # CRITICAL: prevents MissingGreenlet errors
autoflush=False,
)
```
The **`expire_on_commit=False`** setting is non-negotiable for async—without it, accessing any attribute after commit triggers implicit I/O, causing `MissingGreenlet` errors that crash your application.
### Model definition uses typed syntax exclusively
The old `Column(Integer)` style is deprecated. SQLAlchemy 2.0+ infers types from Python annotations:
```python
class Base(AsyncAttrs, DeclarativeBase):
"""Include AsyncAttrs for awaitable_attrs access."""
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
nickname: Mapped[str | None] = mapped_column(String(50)) # Optional = nullable
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
# Relationships with proper async loading
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
lazy="selectin", # NOT "select" which triggers implicit I/O
)
```
**Key anti-pattern to avoid**: Using `lazy="select"` (the default) or `lazy="dynamic"` with async sessions. Both trigger implicit I/O. Use `lazy="selectin"` for collections or `lazy="raise"` to enforce explicit loading.
### Relationship loading requires upfront decisions
For async contexts, you must explicitly load relationships—there's no safe lazy loading:
```python
from sqlalchemy.orm import selectinload, joinedload
# Collections: use selectinload (emits SELECT with IN clause)
stmt = select(User).options(selectinload(User.posts))
# Scalar relationships: use joinedload
stmt = select(Post).options(joinedload(Post.author))
# Chaining for nested relationships
stmt = select(User).options(
selectinload(User.posts).joinedload(Post.category)
)
```
When you need on-demand loading, use the `AsyncAttrs` mixin and `awaitable_attrs`:
```python
async with session:
user = await session.get(User, user_id)
posts = await user.awaitable_attrs.posts # Explicit async load
```
---
## PostgreSQL configuration for async workloads
### Connection pool sizing follows a formula
PostgreSQL's wiki recommends: **connections ≈ (CPU cores × 2) + effective_spindle_count**. For async FastAPI with 100+ concurrent users:
| Scale | pool_size | max_overflow | Total Max |
|-------|-----------|--------------|-----------|
| Medium (100-500 users) | 20-25 | 10-15 | 40 |
| Large (500+ users) | 25-50 | 20-30 | 80 |
**Critical consideration**: Total connections across all workers must not exceed PostgreSQL's `max_connections`. With 4 uvicorn workers at `pool_size=25`, you're consuming 100 connections before overflow.
```python
engine = create_async_engine(
DATABASE_URL,
pool_size=25,
max_overflow=15,
pool_timeout=30,
pool_recycle=1800, # Recycle every 30 minutes
pool_pre_ping=True, # Test connections before use
connect_args={
"command_timeout": 30, # Query timeout in seconds
"statement_cache_size": 200,
"ssl": "require",
},
)
```
### PgBouncer requires special configuration
When using PgBouncer in transaction mode, disable asyncpg's prepared statement cache:
```python
from sqlalchemy.pool import NullPool
engine = create_async_engine(
DATABASE_URL,
poolclass=NullPool, # Let PgBouncer handle pooling
connect_args={
"statement_cache_size": 0, # Disable prepared statements
},
)
```
### UUID v7 is now the recommended primary key strategy
PostgreSQL 18 (September 2025) added native `uuidv7()` function. UUID v7 stores a timestamp in the first 48 bits, providing:
- **Sequential ordering**: Optimal for B-tree indexes (unlike UUID v4's random fragmentation)
- **Distributed generation**: No database coordination required
- **Time-sortable**: Natural chronological ordering
```python
from sqlalchemy.dialects.postgresql import UUID
class Entity(Base):
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
server_default=text("uuidv7()"), # PostgreSQL 18+
)
```
For PostgreSQL < 18, use the `uuid6` Python library:
```python
import uuid6
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid6.uuid7)
```
### PostgreSQL-specific indexes you should know
**GIN indexes** for JSONB containment queries:
```python
Index('ix_metadata_gin', 'metadata', postgresql_using='gin')
```
**Partial indexes** for frequently filtered subsets:
```python
Index('ix_active_users', 'email', postgresql_where=(User.is_active == True))
```
**BRIN indexes** for large, naturally-ordered tables (logs, time-series):
```python
Index('ix_created_at_brin', 'created_at', postgresql_using='brin')
```
---
## FastAPI architecture for scalable applications
### Domain-driven project structure scales better
The 2025 consensus favors **module-based organization** over file-type organization, inspired by Netflix's Dispatch project:
```
src/
├── auth/
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── service.py
│ └── dependencies.py
├── posts/
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── service.py
│ └── dependencies.py
├── config.py # Global configuration
├── database.py # Connection setup
└── main.py
```
Each domain package owns its complete vertical slice. Cross-domain imports use explicit module references to prevent circular imports:
```python
from src.auth import constants as auth_constants
from src.posts.service import PostService
```
### Modern dependency injection uses Annotated types
FastAPI's documentation now emphasizes `Annotated` for cleaner, reusable dependencies:
```python
from typing import Annotated, AsyncIterator
from fastapi import Depends
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# Reusable type alias
SessionDep = Annotated[AsyncSession, Depends(get_session)]
# Usage is clean
@router.post("/users")
async def create_user(data: UserCreate, session: SessionDep):
user = User(**data.model_dump())
session.add(user)
return user
```
### Lifespan context manager replaces startup/shutdown events
The `@app.on_event("startup")` decorator is deprecated. Use the lifespan context manager:
```python
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# STARTUP
app.state.db_engine = create_async_engine(DATABASE_URL)
app.state.session_factory = async_sessionmaker(
app.state.db_engine, expire_on_commit=False
)
# Warm the connection pool
async with app.state.db_engine.begin() as conn:
await conn.execute(text("SELECT 1"))
yield # Application runs
# SHUTDOWN
await app.state.db_engine.dispose()
app = FastAPI(lifespan=lifespan)
```
### Repository pattern versus direct ORM is contextual
The 2025 senior developer consensus:
**Use repository pattern when:**
- Complex business logic requiring transaction coordination
- Multiple data sources or potential database migrations
- Large teams needing clear boundaries
- Comprehensive unit testing requirements
**Direct ORM is acceptable when:**
- Simple CRUD operations
- Rapid prototyping or small services
- Using SQLModel where DTO/DAO are unified
A pragmatic repository implementation:
```python
from typing import Generic, TypeVar
T = TypeVar("T", bound=Base)
class BaseRepository(Generic[T]):
def __init__(self, model: type[T], session: AsyncSession):
self.model = model
self.session = session
async def get(self, id: int) -> T | None:
return await self.session.get(self.model, id)
async def create(self, data: dict) -> T:
obj = self.model(**data)
self.session.add(obj)
await self.session.flush()
await self.session.refresh(obj)
return obj
```
### Background tasks need their own sessions
A critical anti-pattern: **never pass a database session to BackgroundTasks**. The session closes when the request completes, before the background task runs:
```python
# WRONG - session will be closed
@router.post("/")
async def endpoint(session: SessionDep, tasks: BackgroundTasks):
tasks.add_task(some_task, session) # ❌ Session already closed!
# CORRECT - create new session inside task
def background_db_task(user_id: int):
with SessionLocal() as session:
user = session.get(User, user_id)
# Process...
session.commit()
@router.post("/")
async def endpoint(user_id: int, tasks: BackgroundTasks):
tasks.add_task(background_db_task, user_id) # ✅ Pass data, not session
```
For heavy workloads, use **ARQ** (async Redis queue) or **Celery**—both create their own sessions.
---
## Alembic migrations for async environments
### Initialize with the async template
```bash
alembic init -t async migrations
```
The `env.py` requires careful configuration for SQLAlchemy 2.0+:
```python
# alembic/env.py
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy import pool
from app.db import Base
from app.models import * # Import ALL models for autogenerate
target_metadata = Base.metadata
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # Detect column type changes
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
connectable = async_engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
```
### Naming conventions prevent migration headaches
Define naming conventions in your Base class—this ensures consistent, predictable constraint names that Alembic can track:
```python
from sqlalchemy import MetaData
naming_convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=naming_convention)
```
### Zero-downtime migrations use expand-contract pattern
**Phase 1 (Expand)**: Add new columns as nullable, deploy application that writes to both
**Phase 2 (Migrate)**: Backfill data in batches
**Phase 3 (Contract)**: Add constraints, remove old columns
```python
# Phase 1: Add nullable column with server_default
def upgrade():
op.add_column('users',
sa.Column('email_verified', sa.Boolean(),
server_default='false', nullable=False)
)
```
Using `server_default` avoids table locks on large tables.
---
## Docker containerization for production
### Multi-stage builds reduce image size by 70%+
```dockerfile
# Stage 1: Builder
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim AS production
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
COPY --chown=appuser:appuser ./app ./app
USER appuser
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
```
### Docker Compose with proper health checks
```yaml
services:
api:
build:
context: .
target: production
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/app
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
migrate:
build: .
command: ["alembic", "upgrade", "head"]
depends_on:
db:
condition: service_healthy
profiles: ["migrate"]
```
Run migrations separately: `docker compose --profile migrate up migrate`
---
## Authentication uses PyJWT and Argon2 in 2025
### python-jose is deprecated—use PyJWT
FastAPI's official documentation has switched to **PyJWT**. The `python-jose` library had Python 3.10+ compatibility issues and hasn't been updated since 2021:
```python
import jwt
from datetime import datetime, timedelta, timezone
SECRET_KEY = "your-256-bit-secret" # openssl rand -hex 32
ALGORITHM = "HS256"
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
```
### Argon2id replaces bcrypt for password hashing
OWASP and the Password Hashing Competition recommend **Argon2id**. FastAPI's docs now use `pwdlib[argon2]`:
```python
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended() # Uses Argon2id
def verify_password(plain: str, hashed: str) -> bool:
return password_hash.verify(plain, hashed)
def hash_password(password: str) -> str:
return password_hash.hash(password)
```
Argon2id is memory-hard and GPU-resistant—bcrypt uses a fixed 4KB of memory, making it more vulnerable to parallel attacks.
### Token patterns for production
```python
# Access tokens: short-lived (15-30 min), stateless
# Refresh tokens: long-lived (7-30 days), stored in DB for revocation
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
token_hash: Mapped[str] # Store hashed, never plain
expires_at: Mapped[datetime]
revoked: Mapped[bool] = mapped_column(default=False)
```
Implement **token rotation**: issue a new refresh token on each use and invalidate the old one.
---
## Python tooling has consolidated around uv and Ruff
### uv is replacing pip, Poetry, and pyenv
The Astral team's **uv** tool is 10-100x faster than alternatives and handles package management, virtual environments, and Python version management:
```bash
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create project
uv init my-project
uv add fastapi uvicorn sqlalchemy
# Sync dependencies
uv sync
```
### Modern pyproject.toml configuration
```toml
[project]
name = "my-fastapi-app"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"sqlalchemy>=2.0.0",
"asyncpg>=0.30.0",
"pydantic-settings>=2.6.0",
"pyjwt>=2.9.0",
"pwdlib[argon2]>=0.2.0",
]
[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.8.0", "mypy>=1.13.0", "pre-commit>=4.0"]
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "C4", "DTZ", "T20", "RUF"]
```
### Ruff replaces Flake8, Black, and isort
A single tool for linting and formatting:
```bash
ruff check --fix . # Lint with auto-fix
ruff format . # Format code
```
### Pydantic Settings v2 for configuration
```python
from pydantic import SecretStr, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
secrets_dir="/run/secrets", # Docker secrets
)
database_url: PostgresDsn
secret_key: SecretStr
debug: bool = False
settings = Settings()
```
---
## Critical anti-patterns to avoid
**Never share AsyncSession between concurrent tasks**:
```python
# WRONG
await asyncio.gather(process(session, a), process(session, b))
# CORRECT - each task gets its own session
async def process_item(item):
async with SessionLocal() as session:
...
await asyncio.gather(process_item(a), process_item(b))
```
**Don't use legacy Query API**:
```python
# DEPRECATED
session.query(User).filter(User.id == 1).first()
# CORRECT
await session.execute(select(User).where(User.id == 1))
result.scalars().first()
```
**Always dispose engine on shutdown**:
```python
await engine.dispose() # In lifespan shutdown
```
**Don't forget pool_pre_ping in production**—without it, your app will crash when PostgreSQL restarts and connections go stale.
## Conclusion
Building production FastAPI applications in 2025 requires embracing SQLAlchemy 2.0's typed, async-native syntax while avoiding legacy patterns that still pollute many tutorials. The tooling landscape has consolidated: **uv** for package management, **Ruff** for linting, **PyJWT** for tokens, and **Argon2id** for passwords.
The most impactful changes are architectural: using domain-driven project structure, implementing proper connection pool sizing for your scale, and understanding that async sessions cannot be shared or lazily loaded. UUID v7's addition to PostgreSQL 18 finally makes distributed primary keys performant, and the expand-contract migration pattern enables zero-downtime deployments.
Focus on these production fundamentals rather than chasing every new library—the core stack of FastAPI, SQLAlchemy 2.0+, asyncpg, and PostgreSQL is mature and battle-tested.

View File

@ -1,895 +0,0 @@
# Production Pydantic v2 Guide for FastAPI (2025)
Pydantic v2 represents a complete architectural overhaul from v1, with a Rust-powered core (`pydantic-core`) that delivers 5-17x performance improvements. The API has been redesigned around `Annotated` types, new validator patterns, and explicit serialization modes. This guide focuses on 2025 best practices—not tutorial basics—with emphasis on patterns that scale in production FastAPI applications.
---
## Core v2 Changes That Actually Matter
### The Rust Core Changes Everything
Pydantic v2's validation engine is written in Rust and exposed via PyO3. This isn't just "faster validation"—it fundamentally changes how you should think about data processing:
```python
# v1 pattern (deprecated)
from pydantic import BaseModel, validator
class User(BaseModel):
age: int
@validator('age')
def check_age(cls, v):
if v < 18:
raise ValueError('too young')
return v
# v2 pattern (correct)
from pydantic import BaseModel, field_validator
class User(BaseModel):
age: int
@field_validator('age')
@classmethod
def check_age(cls, v: int) -> int:
if v < 18:
raise ValueError('too young')
return v
```
**Key differences:**
- `@field_validator` replaces `@validator`—the signature is now type-safe
- Validators are explicitly `@classmethod` decorated
- No more `each_item` keyword; use `Annotated` for collection items
### Method Name Changes You Must Know
| v1 Method | v2 Replacement | Purpose |
|-----------|----------------|---------|
| `.dict()` | `.model_dump()` | Serialize to dict |
| `.json()` | `.model_dump_json()` | Serialize to JSON string |
| `parse_obj()` | `.model_validate()` | Validate dict/object |
| `parse_raw()` | `.model_validate_json()` | Validate JSON string |
| `from_orm()` | `.model_validate()` + `from_attributes=True` | Load from ORM models |
**Critical:** The old v1 methods still exist but are deprecated. Using them in new code is a mistake.
---
## Annotated: The New Foundation
`Annotated` is now the primary way to attach metadata to fields. This creates reusable, composable type aliases:
```python
from typing import Annotated
from pydantic import BaseModel, Field, AfterValidator
# Reusable validated types
PositiveInt = Annotated[int, Field(gt=0)]
NonEmptyStr = Annotated[str, Field(min_length=1)]
EmailStr = Annotated[str, Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')]
# Composable validation
def check_even(v: int) -> int:
if v % 2 != 0:
raise ValueError('must be even')
return v
EvenPositiveInt = Annotated[int, Field(gt=0), AfterValidator(check_even)]
class Product(BaseModel):
quantity: PositiveInt
sku: NonEmptyStr
batch_size: EvenPositiveInt
```
**Why this matters:** Type aliases centralize validation logic. Change `PositiveInt` once, and every field using it updates automatically.
### Field Metadata vs Validation
```python
from pydantic import Field
class Article(BaseModel):
# Metadata: describes the field, doesn't validate
title: str = Field(
description="Article title",
examples=["How to Scale FastAPI"],
json_schema_extra={"maxLength": 200}
)
# Constraints: actually validate
word_count: int = Field(gt=0, le=10000)
tags: list[str] = Field(min_length=1, max_length=10)
```
**Metadata** appears in OpenAPI/JSON schema but doesn't enforce anything. **Constraints** raise `ValidationError` on violation.
---
## Validators: The Four Modes
Pydantic v2 has four validator types with explicit execution order:
### 1. Before Validators
Run **before** Pydantic's type coercion. Handle raw input:
```python
from pydantic import BaseModel, field_validator
class Event(BaseModel):
timestamp: datetime
@field_validator('timestamp', mode='before')
@classmethod
def parse_timestamp(cls, v):
# v could be str, int, datetime, anything
if isinstance(v, str):
return datetime.fromisoformat(v)
if isinstance(v, int):
return datetime.fromtimestamp(v)
return v # Let Pydantic handle it
```
**Use when:** You need to preprocess raw data before type checking.
### 2. After Validators (default)
Run **after** Pydantic validates the type. Input is guaranteed to match the type annotation:
```python
from pydantic import field_validator
class User(BaseModel):
username: str
@field_validator('username') # mode='after' is default
@classmethod
def normalize_username(cls, v: str) -> str:
return v.lower().strip()
```
**Use when:** You need to transform already-validated data.
### 3. Wrap Validators
Run **around** Pydantic's validation—you control whether to call the handler:
```python
from pydantic import WrapValidator, ValidationError
from typing import Annotated
def truncate_or_fail(v, handler):
try:
return handler(v) # Try normal validation
except ValidationError as e:
if e.errors()[0]['type'] == 'string_too_long':
return v[:100] # Truncate instead of failing
raise
LenientStr = Annotated[str, Field(max_length=100), WrapValidator(truncate_or_fail)]
class Post(BaseModel):
title: LenientStr
```
**Use when:** You need to catch validation errors and recover.
### 4. Plain Validators
Replace Pydantic's validation entirely. **Dangerous** but sometimes necessary:
```python
from pydantic import PlainValidator
from typing import Annotated
def custom_validation(v):
# No type checking happens—you're responsible for everything
return int(v) * 2
WeirdInt = Annotated[int, PlainValidator(custom_validation)]
class Model(BaseModel):
number: WeirdInt
print(Model(number='5').number) # 10
print(Model(number='invalid').number) # 'invalidinvalid' (no error!)
```
**Use when:** Pydantic's type system can't express your validation logic.
### Annotated Validators vs Decorator Syntax
Both styles work; choose based on reusability:
```python
# Annotated: reusable across models
AgeInt = Annotated[int, AfterValidator(lambda v: v if v >= 18 else 18)]
class User(BaseModel):
age: AgeInt
# Decorator: model-specific logic
class User(BaseModel):
age: int
@field_validator('age')
@classmethod
def min_age(cls, v: int) -> int:
return v if v >= 18 else 18
```
### Accessing Other Fields in Validators
Use `ValidationInfo` to access previously validated fields:
```python
from pydantic import field_validator, ValidationInfo
class PasswordReset(BaseModel):
password: str
password_confirm: str
@field_validator('password_confirm')
@classmethod
def passwords_match(cls, v: str, info: ValidationInfo) -> str:
if 'password' in info.data and v != info.data['password']:
raise ValueError('passwords do not match')
return v
```
**Critical:** Field order matters. `password` must be defined before `password_confirm`.
---
## Model Serialization: Python vs JSON Mode
Pydantic v2 has two serialization modes with different outputs:
```python
from datetime import datetime
from pydantic import BaseModel
class Event(BaseModel):
name: str
occurred_at: datetime
tags: set[str]
event = Event(name='Launch', occurred_at='2025-01-01', tags={'python', 'api'})
# Python mode: preserves Python types
print(event.model_dump())
# {'name': 'Launch',
# 'occurred_at': datetime.datetime(2025, 1, 1, 0, 0),
# 'tags': {'python', 'api'}}
# JSON mode: only JSON-compatible types
print(event.model_dump(mode='json'))
# {'name': 'Launch',
# 'occurred_at': '2025-01-01T00:00:00',
# 'tags': ['api', 'python']}
```
**Use JSON mode when:**
- Sending data to FastAPI response models
- Storing in Redis/DynamoDB (requires JSON)
- Passing to `json.dumps()` yourself
**Use Python mode when:**
- Manipulating data in Python
- Passing to other Python functions
- Need access to Python objects (datetime, UUID, etc.)
### The serialize_as_any Gotcha
In v2, nested subclass fields are serialized according to the annotated type, **not the runtime type**:
```python
class User(BaseModel):
name: str
class AdminUser(User):
permissions: list[str]
class Company(BaseModel):
employees: list[User]
admin = AdminUser(name='Alice', permissions=['admin'])
company = Company(employees=[admin])
# v2 default: only User fields serialized
print(company.model_dump())
# {'employees': [{'name': 'Alice'}]} # permissions missing!
# To get v1 behavior, use serialize_as_any
print(company.model_dump(serialize_as_any=True))
# {'employees': [{'name': 'Alice', 'permissions': ['admin']}]}
```
**Fix:** Annotate with `SerializeAsAny` if you need duck-typing:
```python
from pydantic import SerializeAsAny
class Company(BaseModel):
employees: list[SerializeAsAny[User]] # Now includes subclass fields
```
---
## Computed Fields: Properties That Serialize
Computed fields are properties that automatically appear in `model_dump()`:
```python
from pydantic import BaseModel, computed_field
class Rectangle(BaseModel):
width: float
height: float
@computed_field
@property
def area(self) -> float:
return self.width * self.height
rect = Rectangle(width=10, height=5)
print(rect.area) # 50.0
print(rect.model_dump()) # {'width': 10.0, 'height': 5.0, 'area': 50.0}
```
**Critical differences from regular properties:**
- Included in `model_dump()` and `model_dump_json()`
- Appear in JSON schema (marked `readOnly`)
- Cannot be set during initialization
- Only support `alias`, not `validation_alias`/`serialization_alias`
### Computed Fields with Setters
```python
from pydantic import computed_field
class Square(BaseModel):
width: float
@computed_field
@property
def area(self) -> float:
return self.width ** 2
@area.setter
def area(self, value: float):
self.width = value ** 0.5
square = Square(width=4)
square.area = 25 # Sets width to 5.0
print(square.model_dump()) # {'width': 5.0, 'area': 25.0}
```
**Use case:** Exposing derived data in APIs without storing it.
---
## ConfigDict: Model-Level Configuration
`ConfigDict` replaces the v1 `Config` class:
```python
from pydantic import BaseModel, ConfigDict, Field
class StrictModel(BaseModel):
model_config = ConfigDict(
strict=True, # No type coercion
frozen=True, # Immutable (replaces allow_mutation=False)
validate_assignment=True, # Validate on attribute assignment
extra='forbid', # Reject extra fields
from_attributes=True, # Enable ORM mode
str_strip_whitespace=True, # Strip strings automatically
use_attribute_docstrings=True, # Use docstrings as descriptions
)
age: int
"""User's age in years"""
```
### Strict Mode Explained
By default, Pydantic coerces types (lax mode):
```python
class User(BaseModel):
age: int
print(User(age='25').age) # 25 (int, coerced from str)
```
Strict mode disables coercion:
```python
class User(BaseModel):
model_config = ConfigDict(strict=True)
age: int
User(age='25') # ValidationError: Input should be a valid integer
```
**Per-field override:**
```python
class User(BaseModel):
model_config = ConfigDict(strict=True)
age: int
name: str = Field(strict=False) # Only this field allows coercion
```
### from_attributes: The New ORM Mode
Replaces v1's `orm_mode=True`:
```python
from pydantic import ConfigDict
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class User(DeclarativeBase):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str]
class UserSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
# Load from SQLAlchemy model
db_user = session.get(User, 1)
user_data = UserSchema.model_validate(db_user)
```
**Critical:** Without `from_attributes=True`, you can only validate dicts.
---
## TypeAdapter: Validation for Non-Model Types
`TypeAdapter` validates any type, not just `BaseModel`:
```python
from pydantic import TypeAdapter, ValidationError
from typing import TypedDict
class UserDict(TypedDict):
name: str
age: int
UserListAdapter = TypeAdapter(list[UserDict])
# Validate and coerce
data = [{'name': 'Alice', 'age': '30'}]
users = UserListAdapter.validate_python(data)
print(users) # [{'name': 'Alice', 'age': 30}]
# Serialize
json_bytes = UserListAdapter.dump_json(users)
```
**Use cases:**
- Validating API responses (don't want full `BaseModel`)
- Working with `TypedDict` or dataclasses
- Generic container types (`list[int]`, `dict[str, float]`)
### TypeAdapter for Constrained Types
```python
from pydantic import Field
from typing import Annotated
PositiveInt = Annotated[int, Field(gt=0)]
adapter = TypeAdapter(PositiveInt)
adapter.validate_python(5) # OK
adapter.validate_python(-5) # ValidationError
```
---
## Discriminated Unions: Type-Safe Polymorphism
Discriminated unions are FastAPI's secret weapon for handling polymorphic request/response data:
```python
from typing import Literal, Union
from pydantic import BaseModel, Field
class Cat(BaseModel):
type: Literal['cat']
meows: int
class Dog(BaseModel):
type: Literal['dog']
barks: int
class Pet(BaseModel):
pet: Union[Cat, Dog] = Field(discriminator='type')
# Efficient validation—checks 'type' field first
pet = Pet.model_validate({'pet': {'type': 'cat', 'meows': 5}})
print(type(pet.pet)) # <class '__main__.Cat'>
```
### How Discriminators Improve Performance
Without discriminator, Pydantic tries each union member sequentially:
```python
# Slow: tries Cat, fails, tries Dog
pet: Union[Cat, Dog]
```
With discriminator, Pydantic jumps directly to the correct type:
```python
# Fast: reads 'type' field, validates only Dog
pet: Union[Cat, Dog] = Field(discriminator='type')
```
**Production win:** On unions with 5+ members, discriminators are 10x+ faster.
### Nested Discriminated Unions
```python
class BlackCat(BaseModel):
species: Literal['cat']
color: Literal['black']
black_name: str
class WhiteCat(BaseModel):
species: Literal['cat']
color: Literal['white']
white_name: str
Cat = Annotated[Union[BlackCat, WhiteCat], Field(discriminator='color')]
class Dog(BaseModel):
species: Literal['dog']
name: str
Pet = Annotated[Union[Cat, Dog], Field(discriminator='species')]
```
### Fallback Pattern for Unknown Types
Perfect for webhook handlers that must not crash:
```python
from typing import Annotated
class GenericEvent(BaseModel):
type: str
data: dict
class UserCreated(BaseModel):
type: Literal['user.created']
user_id: int
KnownEvents = Annotated[Union[UserCreated], Field(discriminator='type')]
# Outer union tries discriminated first, falls back to generic
Event = Annotated[
Union[KnownEvents, GenericEvent],
Field(union_mode='left_to_right')
]
# Unknown type → GenericEvent (doesn't crash)
Event.model_validate({'type': 'unknown', 'data': {}})
```
**Use case:** Third-party APIs that add new event types without notice.
---
## Alias Patterns: validation_alias vs serialization_alias
Separate aliases for input and output:
```python
from pydantic import BaseModel, Field
class User(BaseModel):
# Accept both snake_case and camelCase on input
first_name: str = Field(validation_alias='firstName')
# Always output as camelCase
last_name: str = Field(
validation_alias='lastName',
serialization_alias='lastName'
)
# Validate with camelCase
user = User(firstName='John', lastName='Doe')
# Serialize with snake_case (default) or camelCase
print(user.model_dump()) # {'first_name': 'John', 'last_name': 'Doe'}
print(user.model_dump(by_alias=True)) # {'first_name': 'John', 'lastName': 'Doe'}
```
### AliasPath: Extract Nested Fields
```python
from pydantic import AliasPath
class User(BaseModel):
first_name: str = Field(validation_alias=AliasPath('name', 'first'))
last_name: str = Field(validation_alias=AliasPath('name', 'last'))
city: str = Field(validation_alias=AliasPath('address', 'city'))
# Validate nested structure
user = User.model_validate({
'name': {'first': 'Jane', 'last': 'Doe'},
'address': {'city': 'NYC', 'zip': '10001'}
})
print(user.model_dump())
# {'first_name': 'Jane', 'last_name': 'Doe', 'city': 'NYC'}
```
**Use case:** Flattening deeply nested API responses.
### AliasChoices: Multiple Valid Aliases
```python
from pydantic import AliasChoices
class User(BaseModel):
username: str = Field(
validation_alias=AliasChoices('username', 'user', 'login')
)
# All of these work
User(username='alice')
User(user='alice')
User(login='alice')
```
**Use case:** Supporting legacy API versions during migration.
### Global Alias Generators
```python
from pydantic import ConfigDict, AliasGenerator
def to_camel(field: str) -> str:
components = field.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
class User(BaseModel):
model_config = ConfigDict(
alias_generator=AliasGenerator(
validation_alias=str.lower, # Accept any case
serialization_alias=to_camel # Output camelCase
)
)
first_name: str
last_name: str
user = User(FIRST_NAME='John', last_name='Doe')
print(user.model_dump(by_alias=True)) # {'firstName': 'John', 'lastName': 'Doe'}
```
---
## FastAPI Integration Patterns
### Request/Response Schema Separation
**Anti-pattern:** Using the same model for input and output:
```python
# DON'T DO THIS
class User(BaseModel):
id: int # Who sets this on creation?
email: str
password: str # Leaked in responses!
```
**Correct pattern:**
```python
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8)
class UserUpdate(BaseModel):
email: str | None = None
password: str | None = Field(None, min_length=8)
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
created_at: datetime
@router.post('/users', response_model=UserResponse)
async def create_user(data: UserCreate):
user = User(email=data.email, password=hash_password(data.password))
session.add(user)
await session.commit()
return user # FastAPI uses response_model to serialize
```
### The response_model Double Validation Trap
FastAPI validates your return value twice:
```python
@router.get('/', response_model=UserResponse)
def get_user():
user = UserResponse(id=1, email='test@example.com') # Validation #1
return user # Validation #2 (FastAPI converts to dict, validates again)
```
**This is intentional:** FastAPI guarantees `response_model` matches output, even if you return the wrong type.
**Performance note:** If you return the exact `response_model` type, the cost is minimal. If you return a dict/ORM object, full validation runs.
### Depend on Pydantic for FastAPI Dependencies
```python
from pydantic import BaseModel, Field
from typing import Annotated
from fastapi import Depends, Query
class Pagination(BaseModel):
page: int = Field(1, ge=1)
size: int = Field(20, ge=1, le=100)
def get_pagination(
page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(ge=1, le=100)] = 20
) -> Pagination:
return Pagination(page=page, size=size)
@router.get('/items')
def list_items(pagination: Annotated[Pagination, Depends(get_pagination)]):
# pagination is validated
return {'page': pagination.page, 'size': pagination.size}
```
---
## Anti-Patterns to Avoid
### Don't Use mutable Defaults
```python
# WRONG
class Team(BaseModel):
members: list[str] = [] # Shared between instances!
# CORRECT
class Team(BaseModel):
members: list[str] = Field(default_factory=list)
```
### Don't Validate in __init__
```python
# WRONG
class User(BaseModel):
email: str
def __init__(self, **data):
super().__init__(**data)
# Custom logic here breaks validation context
self.email = self.email.lower()
# CORRECT
class User(BaseModel):
email: str
@field_validator('email')
@classmethod
def normalize_email(cls, v: str) -> str:
return v.lower()
```
### Don't Forget mode='json' for API Responses
```python
# WRONG - datetime not JSON-serializable
return user.model_dump() # Returns datetime objects
# CORRECT
return user.model_dump(mode='json') # Returns ISO strings
```
### Don't Use Optional Without Default
```python
# WRONG - Optional doesn't add a default!
class User(BaseModel):
nickname: Optional[str] # Still required!
# CORRECT
class User(BaseModel):
nickname: str | None = None # Now optional
```
---
## Performance Optimization
### Precompile Models with Large Field Counts
For models with 50+ fields, validation overhead stacks up:
```python
# Reduce overhead by disabling extra checks
class LargeModel(BaseModel):
model_config = ConfigDict(
validate_assignment=False, # Don't re-validate on setattr
validate_default=False, # Skip default value validation
)
```
### Use model_construct for Trusted Data
Skip validation entirely when data is already validated (e.g., from database):
```python
# Slow: full validation
user = UserResponse(**db_row)
# Fast: no validation
user = UserResponse.model_construct(**db_row)
```
**Warning:** Only use with 100% trusted data. One bad field corrupts your model.
### Cache TypeAdapters
Creating `TypeAdapter` instances is expensive. Cache them:
```python
from functools import lru_cache
@lru_cache
def get_adapter(type_: type) -> TypeAdapter:
return TypeAdapter(type_)
# Reuse adapter
adapter = get_adapter(list[int])
adapter.validate_python([1, 2, 3])
```
---
## Migration Checklist from v1
If you're migrating existing code:
- [ ] Replace `.dict()` with `.model_dump()`
- [ ] Replace `.json()` with `.model_dump_json()`
- [ ] Replace `@validator` with `@field_validator`
- [ ] Replace `Config` class with `ConfigDict`
- [ ] Replace `orm_mode=True` with `from_attributes=True`
- [ ] Update `root_validator` to `@model_validator`
- [ ] Check for `Optional` without defaults
- [ ] Add `mode='json'` where needed for serialization
- [ ] Review discriminated unions for performance
- [ ] Test strict mode on critical endpoints
---
## Conclusion
Pydantic v2's Rust core and redesigned API make it the definitive choice for FastAPI data validation in 2025. The migration from v1 requires learning new patterns—`Annotated` types, validator modes, explicit serialization—but the performance gains and improved type safety justify the investment.
Focus on these production-critical patterns:
1. Use `Annotated` for reusable validation logic
2. Separate request/response schemas (never share models)
3. Apply discriminated unions to speed up polymorphic validation
4. Choose `mode='json'` for API responses, `mode='python'` for internal processing
5. Use `from_attributes=True` when working with ORMs
The v1 → v2 transition is a one-way door. The old patterns won't receive new features, and some are already deprecated. Build new applications on v2 patterns from day one.

View File

@ -1,57 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "spam-eggs"
version = "2020.0.0"
dependencies = [
"httpx",
"gidgethub[httpx]>4.0.0",
"django>2.1; os_name != 'nt'",
"django>2.0; os_name == 'nt'",
]
requires-python = ">=3.8"
authors = [
{name = "Pradyun Gedam", email = "pradyun@example.com"},
{name = "Tzu-Ping Chung", email = "tzu-ping@example.com"},
{name = "Another person"},
{email = "different.person@example.com"},
]
maintainers = [
{name = "Brett Cannon", email = "brett@example.com"}
]
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
license = "MIT"
license-files = ["LICEN[CS]E.*"]
keywords = ["egg", "bacon", "sausage", "tomatoes", "Lobster Thermidor"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python"
]
[project.optional-dependencies]
gui = ["PyQt5"]
cli = [
"rich",
"click",
]
[project.urls]
Homepage = "https://example.com"
Documentation = "https://readthedocs.org"
Repository = "https://github.com/me/spam.git"
"Bug Tracker" = "https://github.com/me/spam/issues"
Changelog = "https://github.com/me/spam/blob/master/CHANGELOG.md"
[project.scripts]
spam-cli = "spam:main_cli"
[project.gui-scripts]
spam-gui = "spam:main_gui"
[project.entry-points."spam.magical"]
tomatoes = "spam:main_tomatoes"
---

View File

@ -1,389 +0,0 @@
# Pytest Production Patterns for FastAPI + Async SQLAlchemy (2025)
**The definitive testing architecture for async-first Python applications combines session-scoped database engines, transaction-based isolation via SQLAlchemy 2.0's `join_transaction_mode`, Polyfactory for type-safe data generation, and pytest-asyncio in auto mode.** This approach delivers sub-second test isolation without recreating tables, handles explicit commits in application code gracefully, and scales to parallel execution with pytest-xdist. The key insight: structure fixtures as a hierarchy where expensive resources (engines, containers) live at session scope while per-test sessions use savepoint rollbacks for isolation.
---
## Pytest 9.x arrives with native TOML and strict mode
Pytest 9.0 (November 2025) introduces significant improvements over the 8.x series. The headline feature is **native TOML configuration** via `[tool.pytest]` instead of the legacy `[tool.pytest.ini_options]` INI-compatibility mode. This enables proper TOML arrays and typed configuration:
```toml
# pyproject.toml (pytest 9.0+)
[tool.pytest]
minversion = "9.0"
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers", "--import-mode=importlib"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
markers = [
"slow: marks tests as slow",
"integration: integration tests requiring database",
]
strict = true # Enables all strictness options
```
The new **`strict = true`** option activates `strict_config`, `strict_markers`, `strict_parametrization_ids`, and `strict_xfail` simultaneously—essential for catching configuration errors in CI. Pytest 9.0 also adds **built-in subtests** (`pytest.Subtests`) for dynamic test generation when values aren't known at collection time, and **`pytest.RaisesGroup`** for testing Python 3.11+ `ExceptionGroup` exceptions.
Breaking changes to note: Python **3.9 support dropped** in 9.0 (3.8 was dropped in 8.4), and test functions returning non-None or containing `yield` now fail explicitly rather than warning. The async behavior changed in 8.4—async tests without a plugin now fail immediately instead of being silently skipped.
---
## pytest-asyncio configuration requires matching loop scopes
The pytest-asyncio ecosystem underwent a major API revision from 0.23 through 1.0 (May 2025). The critical configuration decision is **asyncio_mode**: use `"auto"` for asyncio-only projects to avoid decorating every test and fixture; use `"strict"` only when coexisting with other async frameworks like trio.
```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
asyncio_default_test_loop_scope = "function"
```
The most common pitfall involves **scope mismatches**. Session-scoped async fixtures require session-scoped event loops:
```python
# ❌ WRONG: Session fixture with function-scoped loop
@pytest_asyncio.fixture(scope="session")
async def db_engine(): # Will fail with "attached to different loop"
pass
# ✅ CORRECT: Matching scopes
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def db_engine():
engine = create_async_engine(DB_URL, poolclass=NullPool)
yield engine
await engine.dispose()
```
Note that **pytest-asyncio 1.0 removed the `event_loop` fixture entirely**—use `loop_scope` parameters instead. For fixtures, choose between `@pytest.fixture` (works in auto mode) and `@pytest_asyncio.fixture` (required in strict mode, explicit in either). Always use `NullPool` for async engines in tests to prevent connection leakage between tests.
---
## Conftest architecture balances DRY principles with navigability
The "fat conftest" approach works well when organized thoughtfully. **Root conftest.py should contain cross-cutting fixtures** (database engine, async client, authentication tokens) while **directory-specific conftest files handle overrides and specialized fixtures**.
```
tests/
├── conftest.py # Root: engine, base client, auth fixtures
├── fixtures/
│ ├── database.py # Complex DB setup logic
│ └── factories.py # Polyfactory definitions
├── unit/
│ ├── conftest.py # Mocked DB, isolated fixtures
│ └── test_services.py
└── integration/
├── conftest.py # Real DB session override
└── test_api.py
```
Import shared fixture modules via `pytest_plugins` for explicit control:
```python
# tests/conftest.py
pytest_plugins = [
"tests.fixtures.database",
"tests.fixtures.factories",
]
```
**Fixture scopes should follow a clear hierarchy**: session scope for expensive resources (engines, Docker containers), function scope for test isolation. The key pattern is **session-scoped engine with function-scoped transactional sessions**:
```python
@pytest.fixture(scope="session")
async def db_engine():
engine = create_async_engine(DATABASE_URL, poolclass=NullPool)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture(scope="function")
async def db_session(db_engine):
async with db_engine.connect() as conn:
async with conn.begin() as trans:
session = AsyncSession(
bind=conn,
expire_on_commit=False,
join_transaction_mode="create_savepoint" # Critical!
)
yield session
await session.close()
await trans.rollback()
```
The **`join_transaction_mode="create_savepoint"`** setting is the SQLAlchemy 2.0 solution for handling tested code that calls `session.commit()`—commits become savepoints within the outer transaction, which rolls back completely after the test.
---
## Polyfactory outperforms Factory Boy for async stacks
For FastAPI + async SQLAlchemy + Pydantic v2, **Polyfactory is the clear winner**. It provides native async support, automatic Pydantic constraint validation, and type-safe generics. Factory Boy requires third-party extensions (`async-factory-boy`) and manual workarounds for async operations.
```python
from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory
class UserFactory(SQLAlchemyFactory[User]):
__model__ = User
__set_relationships__ = True
__async_session__ = None # Injected via fixture
# Pydantic schema factory (respects constraints automatically)
from polyfactory.factories.pydantic_factory import ModelFactory
class UserCreateFactory(ModelFactory[UserCreate]):
__model__ = UserCreate
__random_seed__ = 12345 # Deterministic output
```
Configure factories via a fixture to inject the async session:
```python
@pytest.fixture(autouse=True)
def configure_factories(db_session):
UserFactory.__async_session__ = db_session
PostFactory.__async_session__ = db_session
# Usage in tests
async def test_create_user(db_session):
user = await UserFactory.create_async()
assert user.id is not None
# Batch creation
users = await UserFactory.create_batch_async(10)
```
For maximum performance with large datasets, bypass ORM and use SQLAlchemy Core:
```python
from sqlalchemy import insert
async def bulk_create_users(session: AsyncSession, count: int):
users_data = [UserFactory.build() for _ in range(count)]
values = [{"name": u.name, "email": u.email} for u in users_data]
await session.execute(insert(User), values)
await session.commit()
```
**Seed Faker for reproducible tests**—non-deterministic test data causes flaky tests:
```python
@pytest.fixture(scope="session", autouse=True)
def faker_seed():
return 12345
```
---
## Database isolation through transactions beats recreation
The production-ready pattern uses **testcontainers for ephemeral PostgreSQL** and **transaction rollback for per-test isolation**. Never use SQLite as a PostgreSQL substitute—JSONB operators, array types, and savepoint semantics differ fundamentally.
```python
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_container():
container = PostgresContainer("postgres:16-alpine")
container.start()
yield container
container.stop()
@pytest.fixture(scope="session")
async def async_engine(postgres_container):
url = postgres_container.get_connection_url()
async_url = url.replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(async_url, poolclass=NullPool)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
```
For **Alembic migration testing**, use pytest-alembic with dedicated tests rather than running migrations for every test:
```python
# conftest.py
@pytest.fixture
def alembic_config():
return {"script_location": "alembic"}
# Unit tests: use create_all() for speed
# Migration tests: use pytest-alembic's built-in tests
# - test_single_head_revision
# - test_upgrade (base→head)
# - test_up_down_consistency
```
---
## FastAPI testing combines async clients with dependency overrides
Use `httpx.AsyncClient` with `ASGITransport` for async endpoint testing:
```python
from httpx import ASGITransport, AsyncClient
@pytest_asyncio.fixture
async def async_client(db_session):
def get_db_override():
yield db_session
app.dependency_overrides[get_db] = get_db_override
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
yield client
app.dependency_overrides.clear()
```
**Authentication fixtures** should cover valid, expired, and invalid tokens:
```python
@pytest.fixture
def access_token(test_user):
return create_access_token(user_id=test_user.id, expires_delta=timedelta(hours=1))
@pytest.fixture
def expired_token(test_user):
return create_access_token(user_id=test_user.id, expires_delta=timedelta(seconds=-1))
@pytest.fixture
def authenticated_client(async_client, access_token):
async_client.headers["Authorization"] = f"Bearer {access_token}"
return async_client
```
---
## Essential plugins and parallel execution strategy
The 2025 production stack requires these versions:
```toml
[project.optional-dependencies]
test = [
"pytest>=9.0.0",
"pytest-asyncio>=1.0.0",
"pytest-cov>=7.0.0",
"pytest-xdist>=3.8.0",
"pytest-mock>=3.12.0",
"httpx>=0.27.0",
"polyfactory>=2.0.0",
"testcontainers>=4.0.0",
]
```
For **parallel execution with pytest-xdist**, use the `worksteal` scheduler for tests with varying durations:
```bash
pytest -n auto --dist=worksteal
```
When running parallel tests against databases, each worker needs isolation. With testcontainers, **create separate containers per worker**:
```python
@pytest.fixture(scope="session")
def database_url(worker_id):
if worker_id == "master":
return create_single_container()
return create_container_for_worker(worker_id)
```
---
## Complete conftest.py reference implementation
```python
# tests/conftest.py
import asyncio
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import NullPool
from httpx import ASGITransport, AsyncClient
from testcontainers.postgres import PostgresContainer
from app.main import app
from app.database import Base, get_db
pytest_plugins = ["tests.fixtures.factories"]
# Event loop (session-scoped for session fixtures)
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
# PostgreSQL container
@pytest.fixture(scope="session")
def postgres_container():
container = PostgresContainer("postgres:16-alpine")
container.start()
yield container
container.stop()
# Async engine (session-scoped)
@pytest.fixture(scope="session")
async def async_engine(postgres_container):
url = postgres_container.get_connection_url()
async_url = url.replace("postgresql://", "postgresql+asyncpg://")
async_url = async_url.replace("psycopg2", "asyncpg")
engine = create_async_engine(async_url, poolclass=NullPool, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
# Per-test session with transaction rollback
@pytest.fixture(scope="function")
async def db_session(async_engine):
async with async_engine.connect() as conn:
async with conn.begin() as trans:
session = AsyncSession(
bind=conn,
expire_on_commit=False,
join_transaction_mode="create_savepoint"
)
yield session
await session.close()
await trans.rollback()
# Async test client
@pytest.fixture
async def async_client(db_session):
def get_db_override():
yield db_session
app.dependency_overrides[get_db] = get_db_override
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
yield client
app.dependency_overrides.clear()
# Factory configuration
@pytest.fixture(autouse=True)
def configure_factories(db_session):
from tests.fixtures.factories import UserFactory, PostFactory
UserFactory.__async_session__ = db_session
PostFactory.__async_session__ = db_session
# Faker seed for reproducibility
@pytest.fixture(scope="session", autouse=True)
def faker_seed():
return 12345
```
---
## Conclusion
The modern pytest architecture for async FastAPI applications centers on **three key patterns**: transactional isolation via `join_transaction_mode="create_savepoint"`, Polyfactory for type-safe async data generation, and testcontainers for production-parity database testing. Configure pytest-asyncio in auto mode with matching loop scopes, structure fixtures hierarchically (session engine → function session), and embrace pytest 9.0's strict mode for early error detection. This architecture scales from single-threaded development to parallel CI execution while maintaining sub-second test isolation—the foundation for a productive TDD workflow with FastAPI.

View File

@ -1,734 +0,0 @@
# React 19 Production Architecture Guide for Vite SPA (2025)
Building a modern React 19 application with Vite, TanStack Query, Zustand, and React Router requires understanding significant new features and architectural patterns that have matured in 2025. This guide provides senior-level recommendations for your FastAPI template based on comprehensive research of current best practices.
## React 19 delivers production-ready improvements
React 19 (stable since December 2024) introduces **Actions** for async data mutations, new hooks (`useActionState`, `useOptimistic`, `use()`), and refs as regular props without `forwardRef`. The React Compiler reached v1.0 in October 2025 and is now production-ready, automatically handling memoization that developers previously managed with `useMemo` and `useCallback`.
The most impactful changes for your SPA architecture are the new form handling primitives and the elimination of `forwardRef` boilerplate. Server Components remain framework-only (Next.js, Remix) and aren't relevant for Vite SPAs.
### Key React 19 features to adopt immediately
**Actions and async transitions** fundamentally change mutation handling. The `useTransition` hook now supports async functions directly:
```tsx
function UpdateProfile() {
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
const error = await updateProfile(formData);
if (error) setError(error);
});
};
}
```
**`useActionState`** (renamed from `useFormState`) provides built-in pending states and error handling for forms:
```tsx
const [error, submitAction, isPending] = useActionState(
async (prevState, formData) => {
const result = await createItem(formData.get('name'));
return result.error ?? null;
},
null
);
return (
<form action={submitAction}>
<input name="name" />
<button disabled={isPending}>Create</button>
{error && <p>{error}</p>}
</form>
);
```
**`useOptimistic`** enables instant UI feedback while async operations complete—React automatically reverts on failure:
```tsx
const [optimisticItems, addOptimistic] = useOptimistic(items,
(state, newItem) => [...state, { ...newItem, pending: true }]
);
```
**Refs as props** eliminates `forwardRef` boilerplate entirely:
```tsx
// React 19 - ref is just a prop now
function MyInput({ placeholder, ref }: { placeholder: string; ref?: Ref<HTMLInputElement> }) {
return <input ref={ref} placeholder={placeholder} />;
}
```
### React Compiler is production-ready
The React Compiler v1.0 (October 2025) automatically adds memoization at build time, eliminating most manual `useMemo`, `useCallback`, and `React.memo` usage. For Vite, enable it via Babel:
```ts
// vite.config.ts
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
});
```
Run `npx react-compiler-healthcheck@latest` before enabling—your code must follow React's rules (pure components, hooks rules). **For new projects, enable the compiler. For existing code with manual memoization, keep existing optimizations temporarily** while the compiler handles new code.
---
## Entry point and provider architecture
### main.tsx best practices
StrictMode has **zero performance impact in production**—it only runs development checks. Always enable it. React 19 adds new error handling callbacks to `createRoot`:
```tsx
// main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ErrorBoundary } from 'react-error-boundary';
import App from './App';
import './styles.css';
// Initialize monitoring BEFORE React renders
import { initMonitoring } from './lib/monitoring';
initMonitoring();
const root = createRoot(document.getElementById('root')!, {
// React 19: new error callbacks
onUncaughtError: (error, info) => {
monitoring.captureError(error, { componentStack: info.componentStack });
},
onCaughtError: (error, info) => {
monitoring.captureError(error, { severity: 'warning' });
},
});
root.render(
<StrictMode>
<ErrorBoundary FallbackComponent={GlobalErrorFallback}>
<App />
</ErrorBoundary>
</StrictMode>
);
// Service worker registration after render
if ('serviceWorker' in navigator && import.meta.env.PROD) {
import('virtual:pwa-register').then(({ registerSW }) => {
registerSW({ immediate: true });
});
}
```
### Provider ordering matters
Providers can only access context from providers **above** them in the tree. The recommended order (outside → inside): QueryClient → Router → Auth → Theme → Error Boundary → Suspense.
```tsx
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner';
import { routes } from './routes';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000 },
},
});
const router = createBrowserRouter(routes);
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</QueryClientProvider>
);
}
```
**QueryClientProvider wraps Router** so route loaders can access the query client for prefetching. Toast components stay outside error boundaries so they remain functional when errors occur.
### Theme and sidebar state belongs in Zustand
For frequently-changing UI state like theme or sidebar collapse, **Zustand outperforms Context** with selective re-renders and built-in persistence:
```tsx
// stores/ui.store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIStore {
theme: 'light' | 'dark' | 'system';
sidebarCollapsed: boolean;
setTheme: (theme: UIStore['theme']) => void;
toggleSidebar: () => void;
}
export const useUIStore = create<UIStore>()(
persist(
(set) => ({
theme: 'system',
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
}),
{ name: 'ui-storage' }
)
);
```
---
## Shell architecture with React Router layout routes
Use React Router v6+ **layout routes** (pathless routes with `element` and `children`) rather than manual shell wrapper components. This integrates with data loading, provides automatic `Outlet` rendering, and enables multiple distinct layouts.
```tsx
// routes/index.tsx
import { createBrowserRouter } from 'react-router-dom';
export const router = createBrowserRouter([
// Auth layout (centered, no sidebar)
{
element: <AuthLayout />,
children: [
{ path: '/login', lazy: () => import('@/pages/auth/Login') },
{ path: '/register', lazy: () => import('@/pages/auth/Register') },
],
},
// Protected app layout (sidebar + header)
{
element: <ProtectedRoute />,
children: [{
element: <AppShell />,
children: [
{ path: '/', lazy: () => import('@/pages/Dashboard') },
{ path: '/settings', lazy: () => import('@/pages/Settings') },
{ path: '*', element: <NotFound /> },
],
}],
},
]);
```
### The AppShell component
```tsx
// layouts/AppShell.tsx
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
export function AppShell() {
return (
<div className="shell">
<Sidebar />
<div className="shell__main">
<Header />
<main className="shell__content">
<ErrorBoundary FallbackComponent={RouteErrorFallback}>
<Suspense fallback={<PageSkeleton />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</main>
</div>
<ScrollRestoration getKey={(location) => location.pathname} />
</div>
);
}
```
**Shell is inside routes** (as a layout route element) so it can access router context, use `Outlet` for children, and different routes can have different shells.
---
## Protected routes with auth state
The **wrapper component pattern** provides cleaner loading state handling and return-URL preservation than route loaders:
```tsx
// routes/guards/ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth.store';
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
const location = useLocation();
if (isLoading) {
return <AuthLoadingSpinner />;
}
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location.pathname + location.search }}
replace
/>
);
}
return <Outlet />;
}
```
### Return-to-URL handling in Login
```tsx
function Login() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from || '/';
const handleLogin = async (credentials: Credentials) => {
await login(credentials);
navigate(from, { replace: true });
};
}
```
### Role-based access control extension
```tsx
interface ProtectedRouteProps {
allowedRoles?: string[];
}
export function ProtectedRoute({ allowedRoles }: ProtectedRouteProps) {
const { user, isAuthenticated, isLoading } = useAuthStore();
if (isLoading) return <AuthLoadingSpinner />;
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to="/unauthorized" replace />;
}
return <Outlet />;
}
```
---
## Routing patterns with TanStack Query integration
Use `createBrowserRouter` (Data Router) for all new projects—it enables loaders, actions, and per-route error boundaries. **Combine route loaders with TanStack Query**: loaders initiate prefetches early, TanStack Query manages caching and refetching.
```tsx
// Shared query options
const dashboardQueryOptions = queryOptions({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
staleTime: 5 * 60 * 1000,
});
// Route definition with loader prefetch
{
path: '/dashboard',
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(dashboardQueryOptions);
return null;
},
lazy: () => import('./pages/Dashboard'),
}
// Component uses TanStack Query for full benefits
function Dashboard() {
const { data } = useSuspenseQuery(dashboardQueryOptions);
return <DashboardContent data={data} />;
}
```
### Route-based code splitting with `lazy()`
React Router's `lazy()` is superior to `React.lazy()` for routes—it loads component, loader, and error boundary in parallel:
```tsx
// Routes load all exports in parallel
{
path: '/analytics',
lazy: () => import('./routes/analytics'),
}
// routes/analytics.tsx
export async function loader() { return fetchAnalytics(); }
export function Component() { /* ... */ }
export function ErrorBoundary() { return <AnalyticsError />; }
```
---
## Error boundaries and Suspense strategy
Error boundaries remain **class components only** in React 19. Use `react-error-boundary` library for functional wrapper:
```tsx
import { ErrorBoundary } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
// Combined pattern for data fetching
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<Suspense fallback={<PageSkeleton />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
```
### Layered error boundary strategy
- **Global** (in main.tsx): Catches catastrophic failures, shows full-page error
- **Route-level** (in Shell): Isolates page failures, allows navigation to continue
- **Component-level**: Isolates non-critical features (widgets, charts)
### Suspense with useSuspenseQuery
`useSuspenseQuery` in TanStack Query v5 is **production-ready** and guarantees data is defined:
```tsx
function UserProfile({ userId }: { userId: string }) {
// data is always defined - TypeScript knows this!
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <h1>{data.name}</h1>;
}
```
**Avoid waterfall loading** by using `useSuspenseQueries` for parallel fetches or prefetching in route loaders.
---
## Performance optimization with React 19
### When to still use manual memoization
With React Compiler enabled, most memoization is automatic. **Still use manual memoization for**:
- Values used as effect dependencies where you need precise control
- External library integrations requiring reference stability
- Expensive calculations the compiler can't detect
```tsx
// Still useful: effect dependency with specific identity
const chartOptions = useMemo(() => ({
responsive: true,
plugins: { legend: { position: 'top' } }
}), []);
useEffect(() => {
chart.update(chartOptions);
}, [chartOptions]);
```
### useTransition for non-urgent updates
```tsx
function SearchWithTransition() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
setQuery(value); // Urgent: update input immediately
startTransition(() => {
setResults(filterLargeDataset(value)); // Non-urgent
});
};
return (
<>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
<ResultsList results={results} style={{ opacity: isPending ? 0.7 : 1 }} />
</>
);
}
```
### Virtual scrolling for large lists
For lists over **100 items**, use TanStack Virtual (~5.5M weekly downloads) or react-virtuoso (easiest API for dynamic heights):
```tsx
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`,
height: virtualItem.size,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}
```
---
## State management philosophy
### Decision tree for state location
| State Type | Where to Store |
|------------|----------------|
| Server/async data | TanStack Query |
| Global UI state (theme, sidebar) | Zustand |
| Local UI state (dropdown open) | useState |
| URL-shareable state (filters, pagination) | URL search params |
| Form state | React Hook Form |
| Auth tokens | Zustand + persist middleware |
### Zustand TypeScript patterns
```tsx
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (user: User, token: string) => void;
logout: () => void;
}
// Curried create<T>()() required for proper inference with middleware
export const useAuthStore = create<AuthState>()(
devtools(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
login: (user, token) => set({ user, token, isAuthenticated: true, isLoading: false }),
logout: () => set({ user: null, token: null, isAuthenticated: false }),
}),
{ name: 'auth-storage' }
)
)
);
// Selectors for optimized re-renders
export const useUser = () => useAuthStore((s) => s.user);
export const useIsAuthenticated = () => useAuthStore((s) => s.isAuthenticated);
```
---
## TypeScript patterns for React 19
### Component props typing
**Plain functions with typed props are recommended** over `React.FC`:
```tsx
// ✅ Recommended pattern
interface ButtonProps {
variant: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
function Button({ variant, size = 'md', children, onClick }: ButtonProps) {
return <button className={`btn-${variant} btn-${size}`} onClick={onClick}>{children}</button>;
}
```
### Generic components
```tsx
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage - TypeScript infers T
<List
items={users}
renderItem={(user) => <span>{user.name}</span>}
keyExtractor={(user) => user.id}
/>
```
### Event handler typing
```tsx
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
```
---
## Forms with React Hook Form and Zod
```tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await login(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Loading...' : 'Login'}
</button>
</form>
);
}
```
---
## Vite build optimization
```ts
// vite.config.ts
import { defineConfig, splitVendorChunkPlugin } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
splitVendorChunkPlugin(),
],
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-query': ['@tanstack/react-query'],
},
},
},
chunkSizeWarningLimit: 500,
},
});
```
---
## Migration checklist from React 18
```bash
# 1. Update packages
npm install --save-exact react@^19.0.0 react-dom@^19.0.0
npm install --save-exact @types/react@^19 @types/react-dom@^19
# 2. Run codemods
npx codemod@latest react/19/migration-recipe
npx types-react-codemod@latest preset-19 ./src
# 3. Check for compiler compatibility
npx react-compiler-healthcheck@latest
# 4. Add React Compiler
npm install --save-dev babel-plugin-react-compiler@latest
```
**Key breaking changes**: `useRef` requires an argument (`useRef(undefined)` not `useRef()`), ref callbacks can't have implicit returns, `ReactDOM.render` replaced by `createRoot`, `findDOMNode` removed.
---
## Anti-patterns to avoid
- **Don't drill props** through many levels—use Zustand or Context for truly global state
- **Don't overuse Context** for frequently changing state—it triggers full subtree re-renders
- **Don't use array indices as keys** in lists that can reorder or have deletions
- **Don't forget cleanup functions** in useEffect for subscriptions and abort controllers
- **Don't ignore `exhaustive-deps`** ESLint rule—use functional state updates to avoid stale closures
- **Don't create promises in render** when using `use()`—cache them in parent components or use TanStack Query
## Conclusion
For your React 19 + Vite + FastAPI template, adopt the **React Compiler** for automatic memoization, use **layout routes** for shell architecture, combine **route loaders with TanStack Query** for optimal data loading, and keep **Zustand for UI state** while TanStack Query handles server state. The new `useActionState` and `useOptimistic` hooks provide excellent form UX patterns, and `ref` as a prop eliminates forwardRef boilerplate. Layer error boundaries at global, route, and component levels with Suspense boundaries at route transitions for the best user experience.

View File

@ -1,696 +0,0 @@
# Production-Ready SCSS Architecture & Responsive Design for 2025
Modern CSS has fundamentally changed since 2023. **Container queries are now production-ready** with 90%+ browser support, making component-level responsiveness a reality. OKLCH colors enable perceptually uniform palettes, `@layer` provides cascade control, and `text-wrap: balance` eliminates awkward heading breaks. Meanwhile, SCSS's `@import` is officially deprecated—`@use` and `@forward` are mandatory. This report provides senior-level patterns for building a bulletproof design system that works flawlessly from 300px to 1800px+.
## Modern CSS reset for 2025 browsers
The reset landscape has shifted from "erase everything" to "minimal opinionated baseline." Browser consistency is now excellent, so resets focus on improving authoring experience and accessibility rather than fixing bugs.
**Essential reset components for production:**
```scss
// _reset.scss - Modern 2025 Reset
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
// Enable animations to auto/fit-content (Chrome/Edge 2025)
@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
scroll-behavior: smooth;
}
}
body {
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.1;
text-wrap: balance;
overflow-wrap: break-word;
}
p {
text-wrap: pretty;
overflow-wrap: break-word;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
textarea:not([rows]) {
min-height: 10em;
}
// Safari VoiceOver list semantics fix
ul[role='list'], ol[role='list'] {
list-style: none;
}
:target {
scroll-margin-block: 5ex;
}
// SPA root stacking context
#root, #__next {
isolation: isolate;
}
// Safe area for notched devices
@supports(padding: max(0px)) {
body {
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}
}
// Dark mode initialization
html {
color-scheme: dark light;
}
```
### What to add vs remove in 2025
The **new additions** that matter: `interpolate-size: allow-keywords` enables smooth animations to `auto` and `fit-content`; `text-wrap: balance` creates visually balanced headings (max **6 lines** in Chrome); `text-wrap: pretty` prevents orphans in paragraphs. Safe area insets using `env()` with `max()` fallback handle iPhone notches properly.
**Remove these obsolete hacks:** All `-ms-` prefixes (IE11 is completely dead since June 2022), the old `-webkit-appearance: button` form control fixes, monospace font-family double declaration hack, `constant()` for notch (replaced by `env()` since iOS 11.2), and any `height: 100%` on html/body—dynamic viewport units (`dvh`, `svh`) are well-supported now.
### Controversial decisions resolved
**Box-sizing globally**: Still best practice. Performance impact is negligible—Paul Irish confirmed it's "as fast as h1 as a selector." Modern browsers optimize `*` selectors efficiently. The Microsoft Edge team notes "there are more strategic parts to optimize."
**Scroll-behavior smooth**: Only with motion preference check. Apply inside `@media (prefers-reduced-motion: no-preference)` to avoid triggering vestibular disorders. Never apply globally without this check.
**Line-height 1.5**: Apply to body text, but reduce headings to **1.1**. WCAG requires ≥1.5 for body text accessibility, but headings look better with tighter leading.
## Comprehensive design token system
### Spacing scale: 8px base with rem output
The **8px base unit** dominates modern design systems (Atlassian, Material Design, IBM Carbon). Use 4px half-steps for fine control in dense interfaces.
```scss
// _tokens.scss
// Spacing (8px base, rem output)
$space-1: 0.25rem; // 4px
$space-2: 0.5rem; // 8px
$space-3: 0.75rem; // 12px
$space-4: 1rem; // 16px
$space-5: 1.25rem; // 20px
$space-6: 1.5rem; // 24px
$space-8: 2rem; // 32px
$space-10: 2.5rem; // 40px
$space-12: 3rem; // 48px
$space-16: 4rem; // 64px
// CSS custom properties for runtime flexibility
:root {
--space-1: #{$space-1};
--space-2: #{$space-2};
--space-3: #{$space-3};
--space-4: #{$space-4};
--space-6: #{$space-6};
--space-8: #{$space-8};
--space-12: #{$space-12};
--space-16: #{$space-16};
}
```
Use **numeric naming** (`space-1, space-2, space-4`) rather than t-shirt sizes—it maps directly to multipliers and scales infinitely. Always use `rem` for spacing to respect user font-size preferences (accessibility requirement).
### Typography scale: Major Third ratio
The **1.25 (Major Third) ratio** is the most versatile default. It provides clear hierarchy without excessive jumps between sizes.
```scss
// Typography scale (1.25 ratio)
:root {
--font-size-xs: 0.64rem; // ~10px
--font-size-sm: 0.8rem; // ~13px
--font-size-base: 1rem; // 16px
--font-size-md: 1.25rem; // 20px
--font-size-lg: 1.563rem; // 25px
--font-size-xl: 1.953rem; // 31px
--font-size-2xl: 2.441rem; // 39px
--font-size-3xl: 3.052rem; // 49px
// Font weights
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
// Line heights (paired with sizes)
--line-height-tight: 1.1;
--line-height-snug: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
// Tracking
--tracking-tight: -0.025em;
--tracking-normal: 0;
--tracking-wide: 0.025em;
}
```
### Color system: OKLCH is production-ready
**OKLCH is the 2025 standard** with support in Chrome 111+, Safari 15.4+, Firefox 113+. It provides perceptually uniform lightness, better color manipulation (no muddy gradients), wide-gamut P3 support, and native `color-mix()` compatibility.
```scss
// Color tokens using OKLCH
:root {
// Primitives
--color-blue-500: oklch(60% 0.2 250);
--color-blue-600: oklch(50% 0.2 250);
--color-gray-100: oklch(95% 0.01 250);
--color-gray-900: oklch(20% 0.01 250);
// Semantic tokens (light mode)
--color-primary: var(--color-blue-500);
--color-primary-hover: var(--color-blue-600);
--color-text: var(--color-gray-900);
--color-text-muted: oklch(45% 0 0);
--color-bg: oklch(98% 0 0);
--color-surface: oklch(100% 0 0);
// State variants using relative color syntax
--color-primary-disabled: oklch(from var(--color-primary) l calc(c * 0.3) h / 0.5);
}
// Dark mode overrides
[data-theme="dark"] {
--color-text: oklch(95% 0 0);
--color-text-muted: oklch(70% 0 0);
--color-bg: oklch(15% 0 0);
--color-surface: oklch(22% 0 0);
--color-primary: oklch(65% 0.18 250);
}
```
### CSS custom properties vs SCSS variables
**Use both strategically:** SCSS variables for build-time values (breakpoints, calculations, media queries), CSS custom properties for runtime theming. SCSS variables can't be used in media queries—`@media (min-width: $breakpoint-md)` works, but `@media (min-width: var(--breakpoint-md))` does not.
Performance difference is **~0.8% slower** with CSS custom properties—negligible for most applications. The real performance concern is recalculating descendants when changing variables at a parent level.
### Other essential tokens
```scss
:root {
// Border radius
--radius-sm: 0.125rem; // 2px
--radius-md: 0.25rem; // 4px
--radius-lg: 0.5rem; // 8px
--radius-xl: 1rem; // 16px
--radius-full: 9999px; // pill
// Shadows (4-6 levels)
--shadow-sm: 0 1px 2px 0 oklch(0% 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px oklch(0% 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px oklch(0% 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px oklch(0% 0 0 / 0.1);
// Z-index scale
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal: 500;
--z-tooltip: 800;
// Motion
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
// Breakpoints (SCSS only—can't use CSS vars in media queries)
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
```
## Essential production mixins
### Responsive breakpoint mixin
```scss
// _mixins.scss
$breakpoints: (
'sm': 640px,
'md': 768px,
'lg': 1024px,
'xl': 1280px,
'2xl': 1536px
);
@mixin breakpoint($size) {
@media (min-width: map-get($breakpoints, $size)) {
@content;
}
}
// Usage
.card {
padding: 1rem;
@include breakpoint('md') {
padding: 2rem;
}
}
```
### Fluid typography mixin
```scss
@function fluid-type($min-size, $max-size, $min-vw: 320px, $max-vw: 1200px) {
$slope: math.div($max-size - $min-size, $max-vw - $min-vw);
$intercept: $min-size - ($slope * $min-vw);
@return clamp(#{$min-size}, #{$intercept} + #{$slope * 100}vw, #{$max-size});
}
// Usage
h1 {
font-size: fluid-type(1.75rem, 3rem);
}
```
### Container query mixin
```scss
@mixin container-query($name, $min-width) {
@container #{$name} (min-width: #{$min-width}) {
@content;
}
}
// Usage
.card-container {
container: card / inline-size;
}
.card-content {
@include container-query(card, 400px) {
flex-direction: row;
}
}
```
### Accessibility mixins
```scss
// Screen reader only
@mixin sr-only {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
width: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
}
// Modern focus state (WCAG compliant)
@mixin focus-visible-ring($color: currentColor, $offset: 2px) {
&:focus-visible {
outline: 2px solid $color;
outline-offset: $offset;
}
&:focus:not(:focus-visible) {
outline: none;
}
}
// Button reset
@mixin reset-button {
appearance: none;
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
}
// Truncation
@mixin truncate-single {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin truncate-multiline($lines: 3) {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
```
### What NOT to include in mixins
**Vendor prefix mixins are obsolete.** Autoprefixer handles this at build time—manual prefix mixins waste code. Configure `browserslist` and let PostCSS do the work:
```json
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"not op_mini all"
]
}
```
Remove any clearfix hacks (use `display: flow-root` or Grid/Flexbox), aspect ratio mixins (native `aspect-ratio` works everywhere), and hardware acceleration mixins (`will-change` should be applied sparingly and dynamically, not via blanket mixins).
## Flawless responsive design at every pixel width
### Container queries are production-ready
With **90%+ browser support** (Chrome 105+, Firefox 110+, Safari 16+), container queries are ready for production. They enable true component-level responsiveness—components adapt to their container rather than the viewport.
```scss
// Container query pattern
.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: flex;
flex-direction: column;
gap: 1rem;
}
@container card (min-width: 400px) {
.card {
flex-direction: row;
}
}
```
**When to use each approach:**
- **Container queries:** Reusable components (cards, widgets), elements appearing in different contexts (main content vs sidebar)
- **Media queries:** Global page layout, navigation changes, user preference queries (`prefers-reduced-motion`)
### Fluid typography that scales flawlessly
Every font-size should be fluid, not just headings. Use **clamp()** with rem + vw for accessibility compliance—pure `vw` units don't respond to browser zoom (WCAG violation).
```scss
:root {
// Fluid scale from Utopia.fyi
--step-0: clamp(1.13rem, 1.08rem + 0.22vw, 1.25rem);
--step-1: clamp(1.35rem, 1.24rem + 0.55vw, 1.67rem);
--step-2: clamp(1.62rem, 1.41rem + 1.05vw, 2.22rem);
--step-3: clamp(1.94rem, 1.59rem + 1.77vw, 2.96rem);
--step-4: clamp(2.33rem, 1.77rem + 2.81vw, 3.95rem);
// Fluid spacing
--space-s: clamp(1rem, 0.92rem + 0.39vw, 1.25rem);
--space-m: clamp(1.5rem, 1.38rem + 0.58vw, 1.875rem);
--space-l: clamp(2rem, 1.85rem + 0.77vw, 2.5rem);
}
```
### Layouts that never break
The key to avoiding awkward in-between layouts is using **intrinsic sizing patterns** that work at any width:
```scss
// Holy grail grid pattern
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
gap: 1rem;
}
// Sidebar pattern that never breaks
.with-sidebar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.with-sidebar > :first-child {
flex-basis: 20rem;
flex-grow: 1;
}
.with-sidebar > :last-child {
flex-basis: 0;
flex-grow: 999;
min-inline-size: 50%;
}
```
The `min(100%, 250px)` trick prevents overflow on small viewports. Using `auto-fit` with `minmax()` lets the grid automatically adjust column count based on available space—no media queries needed.
### Solving the in-between problem
**More fluid, fewer breakpoints** is the 2025 approach. Use `clamp()` for sizing, let intrinsic sizing work, and reserve breakpoints for major layout shifts. **3-5 breakpoints are optimal** for most sites:
```scss
// Modern breakpoint values
$breakpoints: (
'mobile': 360px, // Small phones
'tablet': 768px, // Tablets portrait
'laptop': 1024px, // Small laptops
'desktop': 1280px, // Standard desktop
'wide': 1440px // Large screens
);
```
**Mobile-first remains the consensus** with 58%+ of traffic being mobile. Content-based breakpoints are preferred over device-based—"The moment your layout looks stretched or awkward—that's your first breakpoint."
## Modern CSS features ready for production
### Production-ready now (use freely)
| Feature | Support | Notes |
|---------|---------|-------|
| `:has()` selector | 90%+ | The "parent selector" everyone wanted |
| `@layer` | All modern | Essential for cascade control |
| Container queries | 90%+ | Component-level responsiveness |
| CSS Nesting | All modern | Native syntax matches Sass |
| Subgrid | 78%+ | Safe with graceful degradation |
| `text-wrap: balance` | 87%+ | Limit: 6 lines Chrome, 10 Firefox |
| `text-wrap: pretty` | 72%+ | Falls back gracefully |
| `color-mix()` | All modern | Use with OKLCH |
| `oklch()` / `oklab()` | All modern | Recommended color format |
### Use with caution (progressive enhancement)
**`@scope`** has no Firefox support (Nightly only). Use with fallbacks. **View Transitions** work in Chrome/Safari 18+ but Firefox is pending. **Anchor positioning** is Chromium-only (Chrome 125+, Edge 125+)—Firefox and Safari are still in development.
### Browser baseline for 2025
Target **Chrome 111+, Safari 16.4+, Firefox 128+**. This aligns with Tailwind CSS v4's baseline. IE11 is completely dead—Microsoft ended support in June 2022. No production sites should support IE11.
## Cascade layers for design systems
`@layer` provides explicit cascade control, eliminating specificity wars with third-party CSS:
```scss
// Declare layer order first—this controls priority
@layer reset, base, tokens, components, utilities, overrides;
@layer reset {
// Your reset styles (lowest priority)
}
@layer components {
.button { /* component styles */ }
}
@layer utilities {
.sr-only { /* highest priority utility */ }
}
// Import third-party CSS into low-priority layer
@import url('library.css') layer(third-party);
```
Key principle: **layer order equals priority order** (last declared wins). Unlayered styles beat all layered styles—be intentional.
## Performance optimization
### content-visibility for long pages
```scss
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px; // Prevent layout shift
}
```
Real-world results show **50-80% reduction in initial rendering time** on content-heavy pages. Apply to off-screen, complex content sections—never above-the-fold content (delays LCP).
### will-change anti-patterns
Never apply `will-change` broadly—it causes performance problems:
```scss
// ❌ Never do this
* { will-change: transform; }
// ✅ Apply dynamically before animation
.element:hover { will-change: transform; }
```
Use as last resort for existing performance problems, apply to few elements, and remove it when animation completes.
### Efficient animation properties
```scss
// ✅ GPU-accelerated, no layout reflow
.efficient-animation {
transform: translateX(100px);
opacity: 0.8;
filter: blur(2px);
}
// ❌ Avoid animating these (cause reflow)
.slow-animation {
width: 200px; // triggers layout
margin: 1rem; // triggers layout
}
```
## Accessibility at every screen size
### Touch targets: updated guidelines
WCAG 2.2 introduced a new AA minimum of **24×24 CSS pixels** (SC 2.5.8), with **44×44px remaining the AAA/best practice** recommendation (SC 2.5.5). Aim for 44×44px on all interactive elements.
### Reduced motion implementation
Use the **motion-reduce-first approach**—default to no motion, add motion only for users who haven't requested reduced motion:
```scss
// Default: no motion
.modal-enter {
opacity: 0;
}
// Add motion for users without preference
@media (prefers-reduced-motion: no-preference) {
.modal-enter {
transform: scale(0.7);
transition: opacity 0.3s, transform 0.3s;
}
}
```
Disable **parallax, large-scale transforms, and zooming completely** for reduced-motion users (vestibular triggers). Micro-interactions can be reduced rather than disabled.
### Dark mode without FOUC
Prevent Flash of Unstyled Content with an **inline blocking script in `<head>`**:
```html
<html data-theme="light">
<head>
<script>
(function() {
const stored = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (systemDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
})();
</script>
</head>
```
### Zoom and reflow requirements
Content must work at **200% zoom** (WCAG 2.1). At 400% zoom (equivalent to 320px viewport), content should reflow to single column without horizontal scrolling:
```scss
@media (max-width: 320px) {
.grid { display: block; }
}
```
## SCSS module system migration
### @import is officially deprecated
Dart Sass deprecated `@import` in version 1.80.0 (2024), with removal scheduled for Dart Sass 3.0.0. Use the migration tool: `sass-migrator module --migrate-deps <entrypoint>`.
### @use and @forward patterns
```scss
// tokens/_colors.scss
$primary: oklch(60% 0.2 250);
$secondary: oklch(55% 0.1 250);
// tokens/_index.scss (barrel file)
@forward 'colors';
@forward 'spacing';
@forward 'typography';
// components/button.scss
@use '../tokens';
.button {
background: tokens.$primary;
padding: tokens.$space-4;
}
```
### Configuring defaults
```scss
// _theme.scss
$primary-color: blue !default;
$border-radius: 4px !default;
// main.scss - Override before loading
@use 'theme' with (
$primary-color: purple,
$border-radius: 8px
);
```
Private members use underscore or dash prefix: `$-private-value` is not accessible externally.
## Conclusion
Senior-level SCSS architecture in 2025 combines **fluid intrinsic layouts** that work at every pixel width, **container queries** for component-level responsiveness, **OKLCH colors** for perceptually uniform palettes, and **cascade layers** for explicit style priority. The shift is clear: fewer media queries, more fluid techniques; fewer hacks, more native CSS features; fewer SCSS variables, more CSS custom properties for theming.
The key insight is that **flawless responsive design comes from intrinsic sizing, not more breakpoints**. Use `repeat(auto-fit, minmax(min(100%, 250px), 1fr))` grids, `clamp()` for typography and spacing, and container queries for component adaptation. Reserve the 3-5 breakpoints for major layout shifts, not incremental adjustments.
Test continuously between breakpoints, not just at them. Use Chrome DevTools' drag-to-resize, test at 200% zoom for accessibility, and validate touch targets are at least 44×44px on mobile. With these patterns, layouts scale smoothly from 300px to 1800px+ without awkward in-between states.

View File

@ -1,593 +0,0 @@
# TanStack Query v5 production architecture for React + FastAPI in 2025
**TanStack Query v5 represents a significant evolution** with unified API signatures, stable Suspense support, and improved TypeScript inference. For a production React + Vite + FastAPI template, the architecture centers on centralized QueryClient configuration, feature-based hook organization using `queryOptions`, Zod runtime validation, Axios interceptors for JWT refresh, and layered error handling. The key insight: v5's `queryOptions` helper eliminates the need for custom wrapper hooks in most cases, while the removal of `onSuccess`/`onError` from queries pushes error handling to global QueryCache callbacks—a cleaner separation of concerns for production applications.
## V5 breaking changes demand immediate attention
The most impactful v5 change is the **unified object syntax**—all hooks now accept only a single object parameter. The old `useQuery(key, fn, options)` pattern is gone:
```typescript
// v4 (multiple signatures) → v5 (single object only)
useQuery(['todos'], fetchTodos, { staleTime: 5000 }) // ❌ Removed
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 5000 }) // ✅ Required
```
**Critical renames** affect every v4 codebase: `cacheTime` becomes `gcTime` (garbage collection time), `isLoading` becomes `isPending` for status checks, and the new `isLoading` now equals `isPending && isFetching`. The `useErrorBoundary` option is now `throwOnError`, and `keepPreviousData` migrates to `placeholderData: keepPreviousData` import.
**Removed features** include query-level `onSuccess`, `onError`, and `onSettled` callbacks. Use global QueryCache callbacks instead:
```typescript
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(`Background update failed: ${error.message}`)
}
}
})
})
```
Infinite queries now **require `initialPageParam`**—it's no longer optional. A codemod exists for migration via `npx jscodeshift` with the `@tanstack/react-query` transform.
## QueryClient configuration for production workloads
The optimal production configuration balances freshness against network efficiency. The **5-minute staleTime default** suits most dashboard-style data, while static reference data like countries or categories should use `Infinity`:
```typescript
// src/core/api/query.config.ts
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query'
import { ApiError, ApiErrorCode } from './errors'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
retry: (failureCount, error) => {
if (error instanceof ApiError) {
// Don't retry auth or not-found errors
if ([ApiErrorCode.AUTHENTICATION_ERROR,
ApiErrorCode.NOT_FOUND,
ApiErrorCode.VALIDATION_ERROR].includes(error.code)) {
return false
}
}
return failureCount < 3
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: true, // Essential for data freshness
refetchOnMount: true,
refetchOnReconnect: true,
networkMode: 'online',
structuralSharing: true, // Maintains referential equality
},
mutations: {
retry: 0, // Mutations shouldn't auto-retry
networkMode: 'online',
},
},
queryCache: new QueryCache({
onError: (error, query) => {
// Only toast for background refetch failures
if (query.state.data !== undefined) {
toast.error(`Update failed: ${error.message}`)
}
Sentry.captureException(error, { extra: { queryKey: query.queryKey } })
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (!mutation.options.onError) {
toast.error(`Operation failed: ${error.message}`)
}
},
}),
})
```
**staleTime recommendations by data type**: Static data (Infinity), semi-static like user profile (**5-30 minutes**), frequently changing dashboard data (**30s-2 minutes**), real-time feeds (**0** with `refetchInterval`). The `gcTime` must exceed `staleTime` to enable instant rendering from cache while background refetch occurs.
## Axios remains the production choice for API configuration
Despite native fetch improvements, **Axios still leads** for production applications due to built-in interceptors, automatic JSON handling, and superior TypeScript support with `AxiosError` type narrowing. For bundle-conscious apps, consider `ky` (~3KB) or `wretch` (~2KB).
```typescript
// src/core/api/api.config.ts
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
const getBaseURL = (): string => {
const env = import.meta.env.MODE
return import.meta.env.VITE_API_BASE_URL || ({
development: '/api/v1', // Uses Vite proxy
staging: 'https://staging-api.example.com/api/v1',
production: 'https://api.example.com/api/v1',
}[env] || '/api/v1')
}
export const apiClient: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
})
```
The **Vite proxy configuration** eliminates CORS issues in development:
```typescript
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})
```
## JWT refresh flow belongs in Axios interceptors
The **hybrid approach** is optimal: token injection and refresh in Axios interceptors, network retry in TanStack Query. This keeps auth concerns centralized while leveraging TanStack Query's built-in retry for transient failures:
```typescript
// src/core/api/interceptors.ts
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
apiClient.interceptors.request.use((config) => {
const token = tokenStorage.getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh')) {
if (isRefreshing) {
// Queue requests during refresh
return new Promise((resolve) => {
refreshSubscribers.push((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(apiClient(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { access_token, refresh_token } = await refreshTokens()
tokenStorage.setAccessToken(access_token)
if (refresh_token) tokenStorage.setRefreshToken(refresh_token)
// Retry queued requests
refreshSubscribers.forEach((cb) => cb(access_token))
refreshSubscribers = []
originalRequest.headers.Authorization = `Bearer ${access_token}`
return apiClient(originalRequest)
} catch {
tokenStorage.clearTokens()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
return Promise.reject(transformAxiosError(error))
}
)
```
## Query key factories with queryOptions define the modern pattern
The v5 `queryOptions` helper provides **type-safe query definitions** that work everywhere—`useQuery`, `useSuspenseQuery`, `prefetchQuery`, and cache operations. This replaces the need for custom hooks in many cases:
```typescript
// src/api/hooks/useUsers.ts
import { queryOptions, useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { userService } from '../services/users'
// Query key factory with embedded queryOptions
export const userQueries = {
all: () => ['users'] as const,
lists: () => [...userQueries.all(), 'list'] as const,
list: (filters: { page?: number; limit?: number }) =>
queryOptions({
queryKey: [...userQueries.lists(), filters] as const,
queryFn: () => userService.getAll(filters),
staleTime: 1000 * 60 * 5,
}),
details: () => [...userQueries.all(), 'detail'] as const,
detail: (id: number) =>
queryOptions({
queryKey: [...userQueries.details(), id] as const,
queryFn: () => userService.getById(id),
staleTime: 1000 * 60 * 2,
}),
}
// Usage - type safety flows automatically
export const useUsers = (page = 1, limit = 10) =>
useQuery(userQueries.list({ page, limit }))
export const useUser = (id: number) =>
useQuery({ ...userQueries.detail(id), enabled: !!id })
// Cache operations are type-safe
const queryClient = useQueryClient()
queryClient.setQueryData(userQueries.detail(5).queryKey, updatedUser) // Typed!
```
**Hook organization follows feature-based structure**: queries and mutations co-located in feature folders, not a global `queryKeys.ts`. The pattern `todoKeys.all → todoKeys.lists() → todoKeys.list(filters)` enables hierarchical invalidation.
## Mutations require explicit callback separation
TanStack Query recommends **separating logic callbacks from UI callbacks**. Logic in `useMutation` runs even if the component unmounts; UI actions in `mutate()` call don't:
```typescript
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userService.update,
// Logic: always runs
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: userQueries.detail(newData.id).queryKey })
const previousUser = queryClient.getQueryData(userQueries.detail(newData.id).queryKey)
queryClient.setQueryData(userQueries.detail(newData.id).queryKey, (old) => ({ ...old, ...newData }))
return { previousUser }
},
onError: (err, newData, context) => {
queryClient.setQueryData(userQueries.detail(newData.id).queryKey, context?.previousUser)
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({ queryKey: userQueries.detail(variables.id).queryKey })
},
})
}
// Component usage
const updateUser = useUpdateUser()
updateUser.mutate(userData, {
// UI: only runs if component still mounted
onSuccess: () => navigate('/users'),
})
```
**Optimistic updates via UI** (using `mutation.variables` in render) is simpler than cache manipulation for many cases, with automatic cleanup on error.
## Zod validates at the API boundary for type safety
**Validate in the query function**, not in components. Zod remains the recommended choice for 2025 due to ecosystem maturity, though Valibot offers 90%+ smaller bundles:
```typescript
// src/api/types/user.types.ts
import { z } from 'zod'
export const userSchema = z.object({
id: z.number(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
})
export const usersResponseSchema = z.object({
data: z.array(userSchema),
total: z.number(),
page: z.number(),
})
export type User = z.infer<typeof userSchema>
export type UsersResponse = z.infer<typeof usersResponseSchema>
// src/api/services/users.ts
export const userService = {
getAll: async (params: { page?: number; limit?: number }): Promise<UsersResponse> => {
const response = await apiClient.get('/users', { params })
return usersResponseSchema.parse(response.data) // Runtime validation
},
getById: async (id: number): Promise<User> => {
const response = await apiClient.get(`/users/${id}`)
return userSchema.parse(response.data)
},
}
```
**Validation errors should be caught and transformed** into actionable API errors, not allowed to crash the application. Use `safeParse` when you need graceful handling.
## Error handling flows through three layers
The production pattern establishes **three error handling layers**: Axios interceptor transforms errors, QueryCache handles global concerns, and component-level handles specific UI:
```typescript
// src/core/api/errors.ts
export enum ApiErrorCode {
NETWORK_ERROR = 'NETWORK_ERROR',
VALIDATION_ERROR = 'VALIDATION_ERROR',
AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR',
NOT_FOUND = 'NOT_FOUND',
SERVER_ERROR = 'SERVER_ERROR',
}
export class ApiError extends Error {
constructor(
message: string,
public readonly code: ApiErrorCode,
public readonly statusCode: number,
public readonly details?: Record<string, string[]>
) {
super(message)
this.name = 'ApiError'
}
getUserMessage(): string {
const messages: Record<ApiErrorCode, string> = {
[ApiErrorCode.NETWORK_ERROR]: 'Unable to connect. Check your internet.',
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input.',
[ApiErrorCode.AUTHENTICATION_ERROR]: 'Session expired. Please log in.',
[ApiErrorCode.NOT_FOUND]: 'Resource not found.',
[ApiErrorCode.SERVER_ERROR]: 'Something went wrong. Try again.',
}
return messages[this.code] || this.message
}
}
// Register global error type
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError
}
}
```
**throwOnError configuration** determines Error Boundary behavior. Use a function for granular control: `throwOnError: (error) => error.statusCode >= 500` sends only server errors to boundaries.
## Loading states changed significantly in v5
The **naming changes** affect every component: v4's `isLoading` (no data yet) is now `isPending`, while v5's `isLoading` means `isPending && isFetching` (first fetch in flight). Use `isRefetching` (equals `isFetching && !isPending`) for background update indicators:
```typescript
function UserList() {
const { data, isPending, isFetching, isRefetching } = useQuery(userQueries.list({}))
if (isPending) return <Skeleton /> // Initial load
return (
<div className={isRefetching ? 'opacity-75' : ''}>
{data.map(user => <UserCard key={user.id} {...user} />)}
{isRefetching && <RefreshIndicator />}
</div>
)
}
```
**placeholderData with keepPreviousData** prevents loading flicker during pagination:
```typescript
import { keepPreviousData } from '@tanstack/react-query'
const { data, isPlaceholderData } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
placeholderData: keepPreviousData,
})
```
## Suspense is production-ready with dedicated hooks
**useSuspenseQuery is now stable** in v5 and guarantees `data` is never undefined. The key difference: `enabled`, `placeholderData`, and error callbacks aren't available—use component composition for conditional queries:
```typescript
import { useSuspenseQuery, QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
function UserProfile({ userId }: { userId: number }) {
const { data } = useSuspenseQuery(userQueries.detail(userId))
// data is User, never undefined
return <div>{data.firstName}</div>
}
// Parent provides Suspense and Error boundaries
function UserProfilePage({ userId }: { userId: number }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => (
<button onClick={resetErrorBoundary}>Retry</button>
)}>
<Suspense fallback={<Skeleton />}>
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
}
```
**Avoid waterfall requests** with `useSuspenseQueries` for parallel data fetching within Suspense boundaries.
## Caching strategies vary by data volatility
| Data Type | staleTime | gcTime | Strategy |
|-----------|-----------|--------|----------|
| Static reference (countries) | `Infinity` | `Infinity` | Fetch once, cache forever |
| User profile | 5-30 min | 1 hour | Background refresh on focus |
| Dashboard metrics | 30s-2 min | 10 min | Frequent background updates |
| Real-time (prices) | 0 | 1-5 min | Use `refetchInterval` |
**Prefetching on hover** improves perceived performance dramatically:
```typescript
function UserLink({ userId }: { userId: number }) {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery(userQueries.detail(userId))
}
return (
<Link
to={`/users/${userId}`}
onMouseEnter={prefetch}
onFocus={prefetch}
>
View User
</Link>
)
}
```
## Offline support uses persistence plugins
For offline-first applications, combine `PersistQueryClientProvider` with LocalStorage (small caches) or IndexedDB (large datasets):
```typescript
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'QUERY_CACHE',
throttleTime: 1000,
})
// gcTime must match or exceed persistence maxAge
const queryClient = new QueryClient({
defaultOptions: {
queries: { gcTime: 1000 * 60 * 60 * 24 }, // 24 hours
},
})
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24,
buster: APP_VERSION, // Invalidate on version change
}}
>
<YourApp />
</PersistQueryClientProvider>
)
}
```
## WebSocket integration updates cache directly
For real-time features, **update the query cache from WebSocket events**. Use `setQueryData` for frequent small updates, `invalidateQueries` for complex state changes:
```typescript
useEffect(() => {
const socket = io(WS_URL)
socket.on('user:updated', (user: User) => {
queryClient.setQueryData(userQueries.detail(user.id).queryKey, user)
queryClient.setQueryData(userQueries.list({}).queryKey, (old) =>
old?.data.map(u => u.id === user.id ? user : u)
)
})
socket.on('user:created', () => {
queryClient.invalidateQueries({ queryKey: userQueries.lists() })
})
return () => socket.disconnect()
}, [queryClient])
```
## Testing patterns use QueryClient wrapper
```typescript
// test-utils.tsx
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
},
})
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient()
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
}
// With MSW for API mocking
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/users/:id', ({ params }) =>
HttpResponse.json({ id: params.id, name: 'Test User' })
)
)
```
## DevTools load conditionally in production
DevTools are **automatically excluded in production builds**. For on-demand production debugging, lazy-load from the production bundle:
```typescript
const ReactQueryDevtoolsProduction = lazy(() =>
import('@tanstack/react-query-devtools/production').then((d) => ({
default: d.ReactQueryDevtools,
}))
)
// Toggle with window.toggleDevtools() in console
```
## Recommended file structure synthesizes all patterns
```
src/
├── core/
│ └── api/
│ ├── query.config.ts # QueryClient with defaults
│ ├── api.config.ts # Axios instance + base config
│ ├── interceptors.ts # Auth + error interceptors
│ └── errors.ts # ApiError class + transformer
├── api/
│ ├── hooks/
│ │ ├── useUsers.ts # Query factories + hooks + mutations
│ │ ├── usePosts.ts
│ │ └── useAuth.ts
│ ├── services/
│ │ ├── users.ts # Type-safe API functions with Zod
│ │ ├── posts.ts
│ │ └── auth.ts
│ └── types/
│ ├── user.types.ts # Zod schemas + inferred types
│ ├── post.types.ts
│ └── common.types.ts # Shared schemas (pagination, etc.)
├── components/
│ └── providers/
│ └── QueryProvider.tsx # QueryClientProvider + DevTools
└── App.tsx
```
## Conclusion
Building a production TanStack Query v5 architecture requires embracing the **unified object API**, leveraging `queryOptions` for type-safe query definitions, and establishing clear boundaries between API configuration (Axios interceptors), caching behavior (QueryClient defaults), and UI concerns (component-level error handling). The removal of per-query callbacks in v5 isn't a limitation—it's a forcing function toward cleaner global error handling via QueryCache.
The key anti-patterns to avoid: storing JWTs in localStorage (use HttpOnly cookies + memory), over-using custom hooks when `queryOptions` suffices, handling auth refresh in TanStack Query's retry logic (interceptors are cleaner), and neglecting `staleTime` configuration (the default `0` causes excessive refetching). For FastAPI backends, the Axios + Zod combination provides the strongest type safety from API response to component render.

View File

@ -1,570 +0,0 @@
# ty - Extremely Fast Python Type Checker
**Official Docs**: https://docs.astral.sh/ty
## What is ty?
ty is an **extremely fast** Python type checker written in Rust by Astral (the creators of uv and Ruff). It's designed to be:
- **10-100x faster** than mypy and pyright
- **Zero configuration** to get started
- **Compatible** with existing type annotations
- **Production-ready** for large codebases
Think: "Ruff for type checking" - blazing fast, modern, and built for scale.
---
## Installation
### Add to your project dependencies:
```bash
# With uv (recommended)
uv add --dev ty
# With pip
pip install ty
```
Or run it directly without installing:
```bash
uvx ty check
```
---
## Quick Start
### 1. Basic Usage
```bash
# Check entire project
ty check
# Check specific files/directories
ty check src/
ty check src/models/User.py
# Watch mode (recheck on file changes)
ty check --watch
```
### 2. Exit Codes
- `0` - No errors
- `1` - Type errors found
- `2` - Invalid config/CLI options
- `101` - Internal error
### 3. Output Formats
```bash
# Default verbose output with context
ty check
# Concise (one per line)
ty check --output-format concise
# GitHub Actions annotations
ty check --output-format github
# GitLab Code Quality JSON
ty check --output-format gitlab
```
---
## Configuration
### Option 1: pyproject.toml (Recommended)
```toml
[tool.ty]
# Python version (auto-detected from requires-python if not set)
python-version = "3.12"
# Source directories
[tool.ty.src]
include = ["src", "tests"]
exclude = ["src/generated/**", "*.proto"]
# Python environment (auto-detected from .venv if not set)
[tool.ty.environment]
root = ["./src"]
python = "./.venv"
# Rule severity configuration
[tool.ty.rules]
# Make warnings errors
possibly-missing-attribute = "error"
possibly-missing-import = "error"
# Downgrade errors to warnings
division-by-zero = "warn"
# Disable specific rules
redundant-cast = "ignore"
unused-ignore-comment = "ignore"
# Override rules for specific files
[[tool.ty.overrides]]
include = ["tests/**"]
[tool.ty.overrides.rules]
unresolved-reference = "warn"
# Terminal output
[tool.ty.terminal]
error-on-warning = false # exit code 1 if warnings exist
output-format = "full" # full | concise | github | gitlab
```
### Option 2: ty.toml (Alternative)
Create `backend/ty.toml` (same structure, no `[tool.ty]` prefix):
```toml
python-version = "3.12"
[src]
include = ["src", "tests"]
[rules]
possibly-unresolved-reference = "warn"
```
---
## Important Rules
### Error-Level (Default)
These **will fail** your CI/CD:
| Rule | What it catches |
|------|----------------|
| `call-non-callable` | Calling non-callable objects: `4()` |
| `division-by-zero` | Division by zero: `5 / 0` |
| `unresolved-import` | Missing modules: `import nonexistent` |
| `unresolved-reference` | Undefined variables: `print(undefined_var)` |
| `unresolved-attribute` | Missing attributes: `obj.missing_attr` |
| `invalid-argument-type` | Wrong arg types: `func(x: int)` called with `func("str")` |
| `invalid-return-type` | Return type mismatch |
| `missing-argument` | Missing required args: `func(x: int)` called as `func()` |
| `unknown-argument` | Unknown kwargs: `func(x=1, unknown=2)` |
| `unsupported-operator` | Bad operators: `"string" + 123` |
| `invalid-assignment` | Type mismatch: `x: int = "string"` |
### Warning-Level (Default)
Won't fail CI unless you enable `--error-on-warning`:
| Rule | What it catches |
|------|----------------|
| `possibly-unresolved-reference` | Variables that **might** not be defined (conditional) |
| `possibly-missing-attribute` | Attributes that **might** not exist (conditional) |
| `possibly-missing-import` | Imports that **might** be missing (conditional) |
| `redundant-cast` | Unnecessary `cast()` calls |
| `deprecated` | Usage of deprecated APIs |
| `undefined-reveal` | `reveal_type()` without importing it |
### Ignore-Level (Disabled by Default)
Must explicitly enable:
| Rule | What it catches |
|------|----------------|
| `unused-ignore-comment` | Unused `# type: ignore` or `# ty: ignore` |
| `possibly-unresolved-reference` | Possibly undefined refs in conditional code |
| `division-by-zero` | Preview rule - division by zero |
---
## Suppression Comments
### ty-specific suppression
```python
# Suppress specific rule
result = unsafe_operation() # ty: ignore[invalid-argument-type]
# Suppress multiple rules
value = risky() # ty: ignore[unresolved-attribute, invalid-return-type]
# Multi-line expressions (comment on first OR last line)
result = long_function( # ty: ignore[missing-argument]
arg1,
arg2
)
# Combine with other tools
x = 1 # ty: ignore[division-by-zero] # fmt: skip
```
### Standard type: ignore (PEP 484)
```python
# ty respects standard type: ignore
result = something() # type: ignore
# But ty: ignore is preferred for specificity
result = something() # ty: ignore[invalid-return-type]
```
### Disable all checking in a function
```python
from typing import no_type_check
@no_type_check
def untyped_function():
return "anything" + 123 # no errors
```
### Check for unused suppressions
```toml
[tool.ty.rules]
unused-ignore-comment = "warn" # warn about unused suppressions
```
---
## Common Configurations for Production
### Strict Mode (Recommended)
```toml
[tool.ty.rules]
# Treat all "possibly" rules as errors
possibly-missing-attribute = "error"
possibly-missing-import = "error"
possibly-unresolved-reference = "error"
# Catch unused suppressions
unused-ignore-comment = "warn"
# Stricter terminal behavior
[tool.ty.terminal]
error-on-warning = true
```
### Gradual Adoption (Recommended for existing codebases)
```toml
[tool.ty.rules]
# Downgrade strict rules to warnings
unresolved-attribute = "warn"
invalid-argument-type = "warn"
# Focus on critical errors only
[tool.ty.terminal]
error-on-warning = false
```
### FastAPI-Specific
```toml
[[tool.ty.overrides]]
include = ["src/routes/**", "src/dependencies/**"]
[tool.ty.overrides.rules]
# FastAPI uses runtime dependency injection
unresolved-reference = "warn" # for Depends() params
```
---
## CLI Flags Reference
### Rule Control
```bash
# Override rule severity
ty check --error possibly-unresolved-reference
ty check --warn division-by-zero
ty check --ignore redundant-cast
# Can combine multiple
ty check --error rule1 --warn rule2 --ignore rule3
```
### Environment
```bash
# Specify Python environment
ty check --python .venv
# Python version
ty check --python-version 3.12
# Platform
ty check --python-platform linux
ty check --python-platform all # no platform assumptions
```
### Output Control
```bash
# Verbosity
ty check -v # verbose
ty check -vv # very verbose
ty check -q # quiet
ty check -qq # silent
# Exit codes
ty check --exit-zero # always exit 0
ty check --error-on-warning # warnings = exit 1
```
---
## Environment Variables
```bash
# Log level (for debugging ty itself)
TY_LOG=debug ty check
TY_LOG=trace ty check
# Parallelism limit
TY_MAX_PARALLELISM=4 ty check
# Profile performance
TY_LOG_PROFILE=1 ty check # creates tracing.folded
# Python path (additional search paths)
PYTHONPATH=/extra/path ty check
# Virtual environment
VIRTUAL_ENV=/path/to/.venv ty check
```
---
## Integration
### CI/CD (GitHub Actions)
```yaml
name: Type Check
on: [push, pull_request]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --all-extras
- name: Type check
run: uv run ty check --output-format github
```
### Pre-commit Hook
```yaml
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ty
rev: v0.0.1 # use latest version
hooks:
- id: ty
```
### VS Code
```json
// .vscode/settings.json
{
"python.linting.enabled": true,
"python.linting.tyEnabled": true,
"python.linting.tyArgs": ["check"],
}
```
### Just/Makefile
```make
# Makefile
.PHONY: typecheck
typecheck:
ty check
.PHONY: typecheck-watch
typecheck-watch:
ty check --watch
```
---
## ty vs mypy vs pyright
| Feature | ty | mypy | pyright |
|---------|----|----|---------|
| **Speed** | 🚀 10-100x faster | Baseline | Fast (but slower than ty) |
| **Language** | Rust | Python | TypeScript |
| **Config** | Minimal (auto-detects) | Verbose | Verbose |
| **Strictness** | Configurable | Very strict | Very strict |
| **IDE Support** | Growing | Excellent | Excellent (VSCode) |
| **Ecosystem** | New (2024) | Mature (2012) | Mature (2019) |
| **Plugin Support** | Limited | Extensive | Limited |
| **Adoption** | Early | Industry standard | Microsoft standard |
### Migration from mypy
ty is **mostly compatible** with mypy. You can run both in parallel:
```toml
[tool.ty.rules]
# Map mypy behavior to ty
invalid-argument-type = "error" # mypy: arg-type
invalid-return-type = "error" # mypy: return-value
unresolved-attribute = "error" # mypy: attr-defined
```
Key differences:
- ty is **faster** but **less mature**
- mypy has more plugins (e.g., sqlalchemy, django)
- ty auto-detects more (less config needed)
- ty focuses on speed, mypy on completeness
**Recommendation**: Use ty in dev for **fast feedback**, keep mypy in CI for **comprehensive checks** (for now).
---
## Troubleshooting
### ty can't find my virtual environment
```bash
# Explicitly specify
ty check --python .venv
# Or in pyproject.toml
[tool.ty.environment]
python = "./.venv"
```
### False positives in generated code
```toml
[tool.ty.src]
exclude = ["src/generated/**", "alembic/versions/**"]
```
### ty is too strict
```toml
# Downgrade specific rules
[tool.ty.rules]
possibly-missing-attribute = "warn"
possibly-unresolved-reference = "warn"
```
### Performance profiling
```bash
# Generate flamegraph
TY_LOG_PROFILE=1 ty check
# View with flamegraph.pl or speedscope.app
```
---
## Best Practices for This Project
### 1. Use ty for fast local development
```bash
# Quick checks while coding
ty check --watch
```
### 2. Keep mypy for CI completeness
```yaml
# Both in CI
- run: ty check # fast, catches most issues
- run: mypy src/ # thorough, catches edge cases
```
### 3. Suppress intentional violations
```python
# FastAPI dependency injection
async def get_db(db: Annotated[AsyncSession, Depends(get_db_session)]):
# ty might not understand Depends()
return db # ty: ignore[invalid-return-type]
```
### 4. Configure for async/SQLAlchemy
```toml
[[tool.ty.overrides]]
include = ["src/repositories/**", "src/services/**"]
[tool.ty.overrides.rules]
# Async/SQLAlchemy patterns ty might not understand yet
unresolved-attribute = "warn"
```
---
## Key Takeaways
**DO**:
- Use `ty check --watch` during development
- Configure `pyproject.toml` for your project
- Enable `unused-ignore-comment` to keep suppressions clean
- Use `--error-on-warning` in CI for strictness
**DON'T**:
- Blindly suppress errors (investigate first)
- Use `# type: ignore` without rule codes
- Disable important rules globally (use overrides)
- Expect feature parity with mypy (yet)
---
## Quick Reference Card
```bash
# Development
ty check # check everything
ty check --watch # watch mode
ty check src/models/ # specific directory
# CI/CD
ty check --error-on-warning # warnings = errors
ty check --output-format github # GitHub annotations
# Debugging
ty check -vv # very verbose
TY_LOG=debug ty check # ty internal logs
# Configuration
ty check --python .venv # specify venv
ty check --python-version 3.12 # specify version
ty check --error rule-name # override rule severity
```
---
## Resources
- **Official Docs**: https://docs.astral.sh/ty
- **GitHub**: https://github.com/astral-sh/ty
- **Changelog**: https://github.com/astral-sh/ty/releases
- **Rule Reference**: https://docs.astral.sh/ty/reference/rules
- **Astral Blog**: https://astral.sh/blog
---
**Last Updated**: 2025-12-06
**ty Version**: 0.0.1-alpha.30+
**Maintained By**: Astral (creators of uv, Ruff)

View File

@ -1,274 +0,0 @@
# Production-ready React + Vite 6 template for 2025
A senior frontend engineer building a reusable FastAPI + React + TypeScript template in 2025 should use **Vite 6** with **pnpm**, **Biome** for linting/formatting, **Zustand** for client state, **TanStack Query** for server state, and **Tailwind CSS v4** for styling. This configuration prioritizes developer experience, build performance, and production reliability while avoiding over-engineering.
The most significant shift in 2025 is the consolidation of tooling: Biome replaces ESLint + Prettier with **35x faster performance**, ESLint's flat config (`eslint.config.js`) is now mandatory, and Vite 6's Environment API enables better SSR handling. TypeScript's `moduleResolution: "bundler"` is the correct setting for Vite projects, and the `splitVendorChunkPlugin` has been deprecated in favor of manual chunks.
## Vite 6 brings breaking changes that matter
Vite 6 introduced several breaking changes from Vite 5 that affect production templates. The `resolve.conditions` default now explicitly includes `['module', 'browser', 'development|production']`, affecting how packages resolve. JSON stringify behavior changed to `'auto'` mode, and Sass now uses the modern API by default—the legacy API was removed entirely in Vite 7.
A production-ready `vite.config.ts` should handle environment-specific builds, FastAPI proxy setup, and proper chunk splitting:
```typescript
import { defineConfig, loadEnv, type PluginOption } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const isProduction = mode === 'production'
return {
base: env.VITE_BASE_URL || '/',
plugins: [
react(),
tsconfigPaths(),
isProduction && visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}) as PluginOption,
].filter(Boolean),
build: {
target: 'ES2022',
sourcemap: isProduction ? 'hidden' : true,
minify: 'esbuild',
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom'],
'router': ['react-router-dom'],
},
},
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
}
})
```
The `splitVendorChunkPlugin` was deprecated and removed in Vite 7—use `manualChunks` for vendor splitting. Source maps should be `'hidden'` in production to enable error tracking while preventing source code exposure.
## TypeScript configuration requires the bundler resolution strategy
Vite projects require `moduleResolution: "bundler"` rather than `node16`, enabling extensionless imports and proper handling of package.json exports. The multi-file tsconfig approach separates browser (app) and Node.js (config) environments:
```json
// tsconfig.app.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
```
Key TypeScript 5.x features worth enabling include **verbatimModuleSyntax** (enforces explicit type imports), **erasableSyntaxOnly** (ensures transpiler compatibility), and **noUncheckedSideEffectImports** (catches missing side-effect imports). The `vite-tsconfig-paths` plugin automatically syncs path aliases between TypeScript and Vite.
Type-safe environment variables require a declaration file:
```typescript
// src/vite-env.d.ts
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_TITLE: string
}
```
## Biome replaces ESLint and Prettier with dramatic speed gains
**Biome is production-ready in 2025** with 800,000+ weekly npm downloads, 97% Prettier compatibility, and 35x faster performance than ESLint + Prettier combined. Major companies including Shopify, Airbnb, and Mercedes-Benz use it in production.
```json
// biome.json
{
"$schema": "https://biomejs.dev/schemas/1.0.0/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"rules": {
"recommended": true,
"correctness": { "noUnusedVariables": "error" }
}
},
"organizeImports": { "enabled": true }
}
```
Migration from ESLint is straightforward: `npx @biomejs/biome migrate eslint --write`. For teams not ready to switch, ESLint's **flat config** (`eslint.config.js`) is now mandatory—the legacy `.eslintrc` format will be removed in ESLint 10. Use `typescript-eslint` with `eslint-plugin-react`, `eslint-plugin-react-hooks`, and `eslint-plugin-jsx-a11y` for accessibility.
**Oxlint** (50-100x faster than ESLint, written in Rust) reached 1.0 stability and can complement ESLint using `eslint-plugin-oxlint` to disable overlapping rules.
## Project structure should be feature-based for scalability
Feature-based organization groups related code by domain, enabling independent team workflows:
```
src/
├── features/ # Domain modules
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── types.ts
│ └── posts/
├── components/ui/ # Shared primitives (Button, Modal)
├── hooks/ # Shared custom hooks
├── lib/
│ ├── api/client.ts # Axios instance with interceptors
│ └── query/ # TanStack Query config
├── stores/ # Zustand stores
└── types/ # Global TypeScript types
```
**State management hierarchy for 2025**: Use **TanStack Query** for all server state (API data caching), **Zustand** (~2.5KB) for global client state, and **React Context** only for simple shared state like themes. Never store fetched API data in Zustand—let React Query handle caching.
The Zustand pattern with persistence:
```typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useAuthStore = create()(
persist(
(set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}),
{ name: 'auth-storage', partialize: (s) => ({ token: s.token }) }
)
)
```
## Package managers and enterprise tooling choices
**pnpm** is the recommended package manager for production: 70% disk space savings, strict dependency resolution preventing phantom dependencies, and excellent monorepo support. Benchmarks show pnpm with cache/lockfile completing installs in **761ms** versus npm's 1.3s.
| Tool | Recommendation |
|------|----------------|
| Package manager | **pnpm** (or Bun for new projects) |
| Git hooks | **Lefthook** (parallel execution, Go binary) |
| Formatting/linting | **Biome** (or ESLint flat config + Prettier) |
| Testing | **Vitest** with React Testing Library |
| API mocking | **MSW 2.0** for dev and testing |
| Dep updates | **Renovate** (superior to Dependabot for monorepos) |
Essential config files for a production template:
- `.editorconfig` — Cross-IDE consistency, still relevant
- `.nvmrc` — Node version pinning (critical for CI/CD)
- `browserslist` in package.json — Target browser specification
- `lefthook.yml` — Pre-commit linting and type checking
Skip Stylelint when using Tailwind CSS—the Tailwind IntelliSense VS Code extension provides sufficient class ordering.
## Production hardening requires deliberate security measures
**Error tracking** with Sentry requires the `@sentry/vite-plugin` to upload source maps, with `filesToDeleteAfterUpload` to prevent source code leakage:
```typescript
sentryVitePlugin({
sourcemaps: {
filesToDeleteAfterUpload: ["./**/*.map"],
},
})
```
**Critical security practices**:
- Never put secrets in `VITE_` environment variables—they're embedded in the client bundle
- Use `sourcemap: 'hidden'` in production (creates maps for error tracking without exposing them)
- Implement CSP headers via your server/CDN, not meta tags
- React auto-escapes JSX, but never use `dangerouslySetInnerHTML` with user input
**Web Vitals tracking** is essential. The core metrics for 2025 are LCP (<2.5s), INP (<200ms, replaced FID), and CLS (<0.1). Use the attribution build (`web-vitals/attribution`) for debugging performance issues.
For bundle optimization, use `rollup-plugin-visualizer` to identify bloat, implement route-based lazy loading with `React.lazy()`, and preload routes on hover for faster transitions.
## Cutting-edge tools: what's actually production-ready
| Tool | Status | Recommendation |
|------|--------|----------------|
| **Biome** | ✅ Production-ready | Use it—replaces ESLint + Prettier |
| **Bun** | ✅ Stable for package management | Viable alternative to pnpm |
| **Oxlint** | ✅ 1.0 stable | Complement ESLint for speed |
| **Lightning CSS** | ✅ Stable in Vite | Skip if using Tailwind (requires PostCSS) |
| **Rspack** | ✅ 1.0 production-ready | Drop-in Webpack replacement |
| **Turbopack** | ⚠️ Next.js only, alpha for prod | Wait for broader ecosystem support |
Lightning CSS provides 100x faster CSS processing than PostCSS but isn't compatible with Tailwind CSS. Use it only for vanilla CSS workflows.
## CI/CD pipeline essentials
A production CI pipeline should include type checking, linting, bundle size monitoring, and Lighthouse audits:
```yaml
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck # tsc --noEmit
- run: pnpm lint # biome ci
- run: pnpm build
- run: lhci autorun # Lighthouse CI
```
Use **compressed-size-action** or **size-limit** for bundle size monitoring to catch regressions before deployment. Lighthouse CI with performance budgets (`categories:performance > 0.9`) prevents performance degradation.
**Renovate** is superior to Dependabot for monorepos, offering advanced grouping, a dependency dashboard, and support for 90+ package managers versus Dependabot's 14.
## Essential plugins for a production Vite template
```typescript
plugins: [
react(),
tsconfigPaths(), // Path alias sync
svgr({ include: '**/*.svg?react' }), // SVG as components
checker({ typescript: true }), // Dev-time type errors
VitePWA({ registerType: 'autoUpdate' }), // If PWA needed
isProduction && visualizer(), // Bundle analysis
]
```
Include PWA support only when offline capability or installability adds genuine user value—it introduces complexity that many applications don't need.
## The complete recommended stack
For a senior-level, production-ready React + Vite 6 + FastAPI template in 2025:
- **Build tool**: Vite 6 with `manualChunks` for vendor splitting
- **Package manager**: pnpm with strict lockfile
- **TypeScript**: Strict mode, `moduleResolution: "bundler"`, path aliases
- **Linting/formatting**: Biome (or ESLint flat config + Prettier)
- **Styling**: Tailwind CSS v4 with CSS-first configuration
- **State**: TanStack Query (server) + Zustand (client)
- **Routing**: React Router v6 with lazy loading
- **Testing**: Vitest + React Testing Library + MSW
- **Error tracking**: Sentry with hidden source maps
- **Git hooks**: Lefthook + lint-staged
- **CI/CD**: Type check → Lint → Build → Lighthouse
This configuration balances modern tooling with production stability, avoiding bleeding-edge tools that haven't proven enterprise reliability while embracing genuinely superior alternatives like Biome that have earned industry trust.

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +0,0 @@
=========
Changelog
=========
All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`_,
and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`_.
----
[Unreleased]
============
Added
-----
Changed
-------
Nothing yet.
Deprecated
----------
Nothing yet.
Removed
-------
Nothing yet.
Fixed
-----
Nothing yet.
Security
--------
Nothing yet.
----
Changed
-------
----
[0.1.0] - 2025-xyz-xyz
====================
Initial release.
Added
-----

View File

@ -1,104 +0,0 @@
====================================
Contributing to XYZ
====================================
Thank you for your interest in contributing to **XYZ**!
----
Development Setup
=================
1. Clone the repository::
git clone https://github.com/USERNAME/XYZ.git
cd XYZ
2. ...
3. ...
4. ...
5. ...
----
Code Style
==========
This project follows strict coding standards:
Formatting
----------
- **Formatter**: Use ``yapf`` for code formatting
- **Linter**: Use ``ruff`` for linting only (not formatting)
- **Type hints**: Full type hints everywhere using modern syntax (``str | None``, ``list[str]``)
- **Docstrings**: Vertical multi-line format only
- **Imports**: Vertical multi-line for 2+ imports with trailing commas
- **Constants**: All magic numbers and strings must be constants or enums
Run formatters and linters
---------------------------
::
# Format code
yapf -ir src/
# Lint
ruff check src/
# Type check
mypy src/
----
Testing
=======
Run tests before submitting::
uv run pytest
----
Pull Request Process
====================
1. Create a feature branch from ``main``
2. Make your changes following code style guidelines
3. Add tests for new functionality
4. Ensure all tests pass
5. Update documentation if needed
6. Submit a pull request
----
Commit Messages
===============
Use conventional commit format:
- ``feat:`` New features
- ``fix:`` Bug fixes
- ``docs:`` Documentation changes
- ``refactor:`` Code refactoring
- ``test:`` Test additions/changes
- ``chore:`` Maintenance tasks
Example::
feat: XYZ
- XYZ
- XYZ
- XYZ
----
Questions?
==========
Open an issue or reach out to the maintainers!

View File

@ -1,21 +0,0 @@
MIT License
{Example} Copyright ©AngelaMos | 2025 | CarterPerez-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,46 +0,0 @@
==================
Security Policy
==================
**xyz.com | ©xyz | 2025**
----
Reporting a Vulnerability
==========================
We at XYZ take the security of our services and products very seriously.
If you believe you have found a security vulnerability in any of our projects or services,
please report it to us as soon as possible.
How to Report
-------------
Please send a detailed report to our dedicated security email address:
**security@xyz.com**
What to Include in Your Report
-------------------------------
To help us quickly understand, reproduce, and resolve the issue, please include as much
detail as possible in your report:
- A clear description of the vulnerability
- Steps to reproduce the issue
- Potential impact of the vulnerability
- Any suggested fixes (if applicable)
Response Timeline
-----------------
- We will acknowledge receipt of your report within 48 hours
- We will provide a detailed response within 7 days
- We will keep you informed of our progress
Thank You
---------
We appreciate your efforts to responsibly disclose your findings and will make every
effort to acknowledge your contributions.

View File

@ -1,22 +0,0 @@
# Application
APP_NAME="Minimal FastAPI Template"
ENVIRONMENT=development
DEBUG=false
# Server
HOST=0.0.0.0
PORT=8000
RELOAD=true
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
DB_POOL_SIZE=20
DB_MAX_OVERFLOW=10
# JWT
SECRET_KEY=your-secret-key-min-32-characters-long
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# CORS
CORS_ORIGINS=["http://localhost:3420","http://localhost:8420"]

View File

@ -1,18 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e ".[dev]"
COPY . .
ENV PYTHONPATH=/app/app
ENV PYTHONUNBUFFERED=1
CMD ["sh", "-c", "alembic upgrade head && uvicorn __main__:app --host 0.0.0.0 --port 8000"]

View File

@ -1,92 +0,0 @@
# Minimal FastAPI Template
A minimal production-ready FastAPI template with JWT authentication and PostgreSQL.
## Features
- FastAPI with async/await
- JWT access token authentication
- PostgreSQL with SQLAlchemy 2.0 (async)
- Alembic migrations
- Domain-driven structure
- Docker and docker-compose setup
## Quick Start
1. Copy `.env.example` to `.env` and update the values:
```bash
cp .env.example .env
```
2. Start the services:
```bash
docker-compose up -d
```
The API will be available at http://localhost:8000
API docs at http://localhost:8000/docs
## Project Structure
```
backend/
├── app/
│ ├── auth/ # Authentication domain
│ ├── core/ # Core utilities and shared code
│ ├── user/ # User domain
│ ├── config.py # Application settings
│ ├── factory.py # FastAPI app factory
│ └── __main__.py # Entry point
├── alembic/ # Database migrations
├── pyproject.toml # Dependencies
├── Dockerfile
└── .env.example
```
## Development
Install dependencies:
```bash
cd backend
pip install -e ".[dev]"
```
Run locally:
```bash
cd backend/app
python -m uvicorn __main__:app --reload
```
Create new migration:
```bash
cd backend
alembic revision --autogenerate -m "description"
```
Run migrations:
```bash
cd backend
alembic upgrade head
```
## API Endpoints
- `POST /v1/users` - Register new user
- `POST /v1/auth/login` - Login (returns access token)
- `GET /v1/auth/me` - Get current user
- `POST /v1/auth/logout` - Logout (placeholder)
- `POST /v1/users/change-password` - Change password
- `GET /health` - Health check
- `GET /health/detailed` - Detailed health check
## Extending
This is a minimal starting point. You can add:
- Refresh tokens
- Email verification
- Password reset
- User roles
- Rate limiting (add Redis + slowapi)
- Structured logging (add structlog)
- More domains following the same pattern

View File

@ -1,43 +0,0 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -1,110 +0,0 @@
"""
AngelaMos | 2025
env.py
"""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from config import settings
from core.Base import Base
from user.User import User
config = context.config
def render_item(type_, obj, autogen_context):
"""
Custom renderer for alembic autogenerate
"""
import sqlalchemy as sa
if isinstance(obj, sa.DateTime):
return "sa.DateTime(timezone=True)"
return False
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url() -> str:
"""
Get database URL from settings
"""
return str(settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode
"""
url = get_url()
context.configure(
url = url,
target_metadata = target_metadata,
literal_binds = True,
dialect_opts = {"paramstyle": "named"},
compare_type = True,
compare_server_default = True,
render_item = render_item,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""
Run migrations with connection
"""
context.configure(
connection = connection,
target_metadata = target_metadata,
compare_type = True,
compare_server_default = True,
render_item = render_item,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""
Run migrations in async mode
"""
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix = "sqlalchemy.",
poolclass = pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode
"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -1,25 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -1,37 +0,0 @@
"""initial_schema
Revision ID: 000001
Revises:
Create Date: 2025-12-15 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '000001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('hashed_password', sa.String(length=1024), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users')),
sa.UniqueConstraint('email', name=op.f('uq_users_email'))
)
op.create_index(op.f('ix_email'), 'users', ['email'], unique=True)
def downgrade() -> None:
op.drop_index(op.f('ix_email'), table_name='users')
op.drop_table('users')

View File

@ -1,19 +0,0 @@
"""
AngelaMos | 2025
__main__.py
"""
import uvicorn
from config import settings
from factory import create_app
app = create_app()
if __name__ == "__main__":
uvicorn.run(
"__main__:app",
host = settings.HOST,
port = settings.PORT,
reload = settings.RELOAD,
)

View File

@ -1,20 +0,0 @@
"""
AngelaMos | 2025
dependencies.py
"""
from typing import Annotated
from fastapi import Depends
from core.dependencies import DBSession
from .service import AuthService
def get_auth_service(db: DBSession) -> AuthService:
"""
Dependency for Auth service
"""
return AuthService(db)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]

View File

@ -1,63 +0,0 @@
"""
AngelaMos | 2025
routes.py
"""
from typing import Annotated
from fastapi import (
APIRouter,
Depends,
status,
)
from fastapi.security import (
OAuth2PasswordRequestForm,
)
from core.dependencies import CurrentUser
from .schemas import TokenWithUserResponse
from user.schemas import UserResponse
from .dependencies import AuthServiceDep
from core.responses import AUTH_401
router = APIRouter(prefix = "/auth", tags = ["auth"])
@router.post(
"/login",
response_model = TokenWithUserResponse,
responses = {**AUTH_401}
)
async def login(
auth_service: AuthServiceDep,
form_data: Annotated[OAuth2PasswordRequestForm,
Depends()],
) -> TokenWithUserResponse:
"""
Login with email and password
"""
return await auth_service.login(
email=form_data.username,
password=form_data.password,
)
@router.get("/me", response_model = UserResponse, responses = {**AUTH_401})
async def get_current_user(current_user: CurrentUser) -> UserResponse:
"""
Get current authenticated user
"""
return UserResponse.model_validate(current_user)
@router.post(
"/logout",
status_code = status.HTTP_204_NO_CONTENT,
responses = {**AUTH_401}
)
async def logout(_: CurrentUser) -> None:
"""
Logout current session
"""
pass

View File

@ -1,15 +0,0 @@
"""
AngelaMos | 2025
schemas.py
"""
from core.base_schema import BaseSchema
from user.schemas import UserResponse
class TokenWithUserResponse(BaseSchema):
"""
Login response with access token and user data
"""
access_token: str
user: UserResponse

Some files were not shown because too many files have changed in this diff Show More