Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ COPY pyproject.toml uv.lock* ./
# Install dependencies
RUN uv sync --frozen --no-dev

# Copy application code
# Copy application code and migrations
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini start.sh ./

# Expose port
EXPOSE 8000

# Run the application
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run migrations then start the application
CMD ["./start.sh"]
29 changes: 29 additions & 0 deletions alembic/versions/c4e8f2a1b5d7_add_user_name_column.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""add user name column

Revision ID: c4e8f2a1b5d7
Revises: b3c7a1d9e2f4
Create Date: 2026-03-30 00:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "c4e8f2a1b5d7"
down_revision: str | Sequence[str] | None = "b3c7a1d9e2f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Add name column to user table."""
op.add_column("user", sa.Column("name", sa.String(length=255), nullable=True))


def downgrade() -> None:
"""Remove name column from user table."""
op.drop_column("user", "name")
21 changes: 21 additions & 0 deletions app/logging.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
"""Structured logging configuration using structlog."""

import logging
import re
import sys
from typing import Any

import structlog

from app.config import settings

# Keys whose values should be redacted from logs
_PII_KEY_PATTERN = re.compile(
r"(password|token|secret|authorization|cookie|api_key|credential)",
re.IGNORECASE,
)


def pii_scrubbing_processor(
logger: Any, method_name: str, event_dict: dict[str, Any]
) -> dict[str, Any]:
"""Redact values for keys that look like they contain sensitive data."""
for key in list(event_dict):
if _PII_KEY_PATTERN.search(key):
event_dict[key] = "[REDACTED]"
return event_dict


def setup_logging() -> None:
"""Configure structlog with JSON output in production, colored console in dev."""
shared_processors: list[structlog.types.Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
pii_scrubbing_processor,
]

if settings.is_development:
Expand Down
42 changes: 34 additions & 8 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from limits import RateLimitItem, parse
from scalar_fastapi import get_scalar_api_reference
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
Expand All @@ -28,6 +29,7 @@
title="API Template",
description="FastAPI template with async PostgreSQL and cookie-based JWT auth",
version="0.2.0",
docs_url=None,
)

setup_telemetry(app)
Expand Down Expand Up @@ -118,19 +120,34 @@ async def request_id_middleware(request: Request, call_next) -> Response:
return response


@app.middleware("http")
async def cache_control_middleware(request: Request, call_next) -> Response:
"""Set Cache-Control headers: no-store for auth paths, public caching for GETs."""
response = await call_next(request)
if request.url.path.startswith("/auth/"):
response.headers["Cache-Control"] = "no-store"
elif request.method == "GET" and response.status_code == 200:
response.headers["Cache-Control"] = "public, max-age=3600"
return response


_HEALTH_PATHS = frozenset(("/", "/health"))


@app.middleware("http")
async def request_logging_middleware(request: Request, call_next) -> Response:
"""Log method, path, status code, and duration for every request."""
start = time.perf_counter()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 2)
logger.info(
"request",
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
)
if request.url.path not in _HEALTH_PATHS:
duration_ms = round((time.perf_counter() - start) * 1000, 2)
logger.info(
"request",
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
)
return response


Expand All @@ -140,6 +157,15 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
app.include_router(features_router)


@app.get("/docs", include_in_schema=False)
async def scalar_docs():
"""Scalar API documentation."""
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title=app.title,
)


@app.get("/")
async def root():
"""Health check endpoint."""
Expand Down
1 change: 1 addition & 0 deletions app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
- is_verified: email verification status
"""

name: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
role: Mapped[str] = mapped_column(
String(50), default="user", server_default="user", nullable=False
)
5 changes: 3 additions & 2 deletions app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
class UserRead(schemas.BaseUser[UUID]):
"""Schema for reading user data."""

name: str | None = None
role: str = "user"


class UserCreate(schemas.BaseUserCreate):
"""Schema for creating a new user."""

pass
name: str | None = None


class UserUpdate(schemas.BaseUserUpdate):
"""Schema for updating user data."""

pass
name: str | None = None
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies = [
"opentelemetry-sdk>=1.40.0",
"opentelemetry-exporter-otlp-proto-grpc>=1.40.0",
"opentelemetry-instrumentation-fastapi>=0.61b0",
"scalar-fastapi>=1.0.0",
]

[dependency-groups]
Expand Down
8 changes: 8 additions & 0 deletions start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
set -e

# Apply any pending database migrations
uv run alembic upgrade head

# Start the application
exec uv run uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}"
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading