diff --git a/Dockerfile b/Dockerfile index d1cadd6..dc215ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/alembic/versions/c4e8f2a1b5d7_add_user_name_column.py b/alembic/versions/c4e8f2a1b5d7_add_user_name_column.py new file mode 100644 index 0000000..2a2f726 --- /dev/null +++ b/alembic/versions/c4e8f2a1b5d7_add_user_name_column.py @@ -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") diff --git a/app/logging.py b/app/logging.py index ba0116a..0caf239 100644 --- a/app/logging.py +++ b/app/logging.py @@ -1,12 +1,30 @@ """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.""" @@ -14,9 +32,12 @@ def setup_logging() -> None: 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: diff --git a/app/main.py b/app/main.py index ba4e744..d1db77b 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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) @@ -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 @@ -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.""" diff --git a/app/models/user.py b/app/models/user.py index ab217eb..a02c800 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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 ) diff --git a/app/schemas/user.py b/app/schemas/user.py index 5439228..e05b22e 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 9a538a8..14b3de8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..9fd160c --- /dev/null +++ b/start.sh @@ -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}" diff --git a/uv.lock b/uv.lock index f995b3e..befc459 100644 --- a/uv.lock +++ b/uv.lock @@ -78,6 +78,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, + { name = "scalar-fastapi" }, { name = "slowapi" }, { name = "sqlalchemy" }, { name = "structlog" }, @@ -108,6 +109,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.13.0" }, { name = "python-multipart", specifier = ">=0.0.22" }, + { name = "scalar-fastapi", specifier = ">=1.0.0" }, { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0.48" }, { name = "structlog", specifier = ">=25.0.0" }, @@ -1308,6 +1310,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] +[[package]] +name = "scalar-fastapi" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/95/bbbf63b38eafc93862827bda0fba415a486882642d9293f6b918145fd4f9/scalar_fastapi-1.8.1.tar.gz", hash = "sha256:eadb625d386fa94a79e5463af0aa6bcf5e02bc4d665454ed0795960987152531", size = 8258, upload-time = "2026-03-18T09:36:57.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/29/584cc791765b92a35362733875ff881df3b3d181670285eaf571a17170d7/scalar_fastapi-1.8.1-py3-none-any.whl", hash = "sha256:0ff296877d65082039eba8682fef6eafbe1cd77c5b55a3b3c0b96681eb15ca62", size = 7636, upload-time = "2026-03-18T09:36:56.224Z" }, +] + [[package]] name = "slowapi" version = "0.1.9"