docker-security-audit complete - added go lint and dependabot
This commit is contained in:
parent
ad4b17966b
commit
1f241ccd0c
|
|
@ -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:
|
||||
- "*"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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]
|
||||
|
|
@ -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>`_
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__init__.py
|
||||
|
||||
⠀⠀⠀⠀⠀⠴⣦⣤⡀⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⣨⣥⣄⣀⠀⡁⠀⠀⡀⡠⠀⠀⠀⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⢠⣾⣿⣷⣮⣷⡦⠥⠈⡶⠮⣤⣀⡠⠀⡀⣐⣀⡈⠁⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⠟⠀⠠⠊⠉⠀⠀⢀⠉⠙⠚⠧⣦⣀⡀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠠⠀⠁⠀⢤⠀⠀⠀⠨⡉⠛⠶⠤⣄⣄⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⡀⠀⠀⢰⠀⠍⡾⠆⠀⠀⣠⡦⠄⡀⠄⠀⠠⠀⠀⠀⠈⠙⠓⠦⢤⣀⡀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣶⣦⢠⡈⠀⠀⠀⠀⠀⠋⠛⠉⡂⠈⠙⠀⣰⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠺⠦⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠻⢿⣿⣿⣿⣿⣿⣾⣿⣿⣦⢤⡀⢀⣂⣨⠀⢅⢱⡔⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠲⠴⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣎⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣽⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠳⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠹⣦⣴⠖⠲⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠈⠀⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢩⠢⣙⠿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣆⠈⠛⢶⣌⡉⣻⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣷⣄⣤⣙⣿⣿⣿⣷⣄⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠟⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⣿⣿⣿⣿⣿⣿⡿⠋⠉⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠙⠁⠘⢮⣛⡽⠛⠿⡿⠥⠀
|
||||
|
||||
"""
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
},
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
||||
from .correlation import CorrelationIdMiddleware
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CorrelationIdMiddleware",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""
|
||||
AngelaMos | 2025
|
||||
__init__.py
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
||||
---
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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
|
||||
-----
|
||||
|
|
@ -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!
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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')
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Reference in New Issue